Unverified Commit 8594622e authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(panel): implement email OTP verification and passkey registration management

parent e0bc2884
Loading
Loading
Loading
Loading
+5 −0
Changes for internal/api/handlers_user.go: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -175,11 +175,16 @@ func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) {
// the verified token, no lookup escapes it.
func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
	p := principalFromContext(r.Context())
	emailVerified := false
	if u, err := a.Repo.UserByID(r.Context(), p.UserID); err == nil {
		emailVerified = u.EmailVerified
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"user_id":              p.UserID,
		"email":                p.Email,
		"role":                 p.Role,
		"is_admin":             p.IsAdmin(),
		"email_verified":       emailVerified,
		// must_change_password is meaningful only on the local-password path; the JWT
		// path leaves it false. The panel uses it to route a freshly-provisioned staff
		// account straight to the change-password card before any other surface.
+84 −4
Changes for panel/dev/mockApi.ts: 84 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -30,6 +30,7 @@ interface MockAccount {
  email: string;
  linked: boolean;
  mustChangePassword: boolean;
  emailVerified: boolean;
}

interface MockServer extends ServerInfo {
@@ -51,6 +52,7 @@ interface MockState {
  access: Record<string, AccessState>;
  backups: BackupView[];
  builds: Build[];
  passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
}

// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
@@ -167,10 +169,10 @@ function mockBackups(): BackupView[] {
function initialState(): MockState {
  return {
    accounts: {
      owner: account("owner", "admin", true, false),
      user: account("user", "user", false, false),
      linked: account("linked", "user", true, false),
      setup: account("setup", "admin", true, true),
      owner: account("owner", "admin", true, false, false),
      user: account("user", "user", false, false, false),
      linked: account("linked", "user", true, false, true),
      setup: account("setup", "admin", true, true, false),
    },
    images: [
      { image_ref: "registry.felis.svc:5000/paper-1.21:demo", enabled: true, source: "demo" },
@@ -257,6 +259,14 @@ function initialState(): MockState {
        finished_at: new Date(Date.now() - 1700000).toISOString(),
      },
    ],
    passkeys: {
      owner: [
        { id: "pk-1", name: "YubiKey 5C", created_at: new Date(Date.now() - 30 * DAY_MS).toISOString() },
      ],
      linked: [],
      user: [],
      setup: [],
    },
  };
}

@@ -313,12 +323,14 @@ function account(
  role: Role,
  linked: boolean,
  mustChangePassword: boolean,
  emailVerified: boolean,
): MockAccount {
  return {
    id,
    role,
    linked,
    mustChangePassword,
    emailVerified,
    email: `${id}@mock.felis.local`,
  };
}
@@ -406,6 +418,7 @@ function identity(accountInfo: MockAccount): Identity {
    role: accountInfo.role,
    is_admin: accountInfo.role === "admin",
    must_change_password: accountInfo.mustChangePassword,
    email_verified: accountInfo.emailVerified,
  };
}

@@ -568,7 +581,74 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
    case "POST account/link/verify":
      await verifyLinkRoute(ctx);
      return true;
    case "POST account/email/start": {
      const body = await readJSON<{ email?: string }>(ctx.req);
      if (!body.email || !body.email.includes("@")) {
        sendError(ctx.res, 400, "bad_request", "invalid email");
        return true;
      }
      sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() });
      return true;
    }
    case "POST account/email/verify": {
      const body = await readJSON<{ code?: string }>(ctx.req);
      if (body.code?.trim() !== "123456") {
        sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
        return true;
      }
      ctx.account.emailVerified = true;
      sendJSON(ctx.res, 200, { verified: true, email: ctx.account.email });
      return true;
    }
    case "POST account/passkey/register/begin": {
      sendJSON(ctx.res, 200, {
        challenge: "c29tZV9jaGFsbGVuZ2VfZGF0YQ",
        rp: { name: "Felis Dev" },
        user: {
          id: "bW9ja191c2VyX2lk",
          name: ctx.account.email,
          displayName: ctx.account.email,
        },
        pubKeyCredParams: [{ type: "public-key", alg: -7 }],
      });
      return true;
    }
    case "POST account/passkey/register/finish": {
      const body = await readJSON<{ name?: string; attestation?: any }>(ctx.req);
      if (!body.name || !body.attestation) {
        sendError(ctx.res, 400, "bad_request", "name and attestation are required");
        return true;
      }
      const newCred = {
        id: `pk-${Date.now()}`,
        name: body.name.trim(),
        created_at: new Date().toISOString(),
      };
      if (!ctx.state.passkeys[ctx.account.id]) {
        ctx.state.passkeys[ctx.account.id] = [];
      }
      ctx.state.passkeys[ctx.account.id].unshift(newCred);
      sendJSON(ctx.res, 201, newCred);
      return true;
    }
    case "GET account/passkey/credentials": {
      const list = ctx.state.passkeys[ctx.account.id] ?? [];
      sendJSON(ctx.res, 200, { credentials: list });
      return true;
    }
    default:
      if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) {
        const id = ctx.parts[5];
        if (ctx.state.passkeys[ctx.account.id]) {
          const idx = ctx.state.passkeys[ctx.account.id].findIndex((k) => k.id === id);
          if (idx >= 0) {
            ctx.state.passkeys[ctx.account.id].splice(idx, 1);
          }
        }
        ctx.res.statusCode = 204;
        ctx.res.end();
        return true;
      }
      if (await handleImageRoute(ctx)) return true;
      return await handleServerRoute(ctx);
  }
+26 −1
Changes for panel/src/i18n/resources/en-US/account.json: 26 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -17,5 +17,30 @@
  "link_code": "Link code",
  "link_code_placeholder": "ABCD2345",
  "verify_btn": "Link",
  "verifying": "Verifying…"
  "verifying": "Verifying…",
  "email_verification": "Email Verification",
  "email_desc": "Verify your email address to secure your account.",
  "email_verified": "Verified",
  "email_unverified": "Unverified",
  "send_code": "Send Code",
  "sending_code": "Sending…",
  "email_step1": "Enter Email Address",
  "email_step1_desc": "Enter the email address you want to bind to request a verification code.",
  "email_step2": "Enter Verification Code",
  "email_step2_desc": "Enter the 6-digit verification code sent to your email (valid for 10 minutes).",
  "email_verify_btn": "Verify",
  "email_verifying": "Verifying…",
  "email_otp_sent": "Verification code sent.",
  "passkeys": "Passkeys",
  "passkeys_desc": "Passkeys let you log in securely using your fingerprint, face, or screen lock PIN.",
  "no_passkeys": "No registered passkeys.",
  "add_passkey": "Add Passkey",
  "passkey_name": "Device Nickname",
  "passkey_name_placeholder": "e.g., My Phone, YubiKey",
  "registering_passkey": "Registering…",
  "delete_passkey": "Delete",
  "deleting_passkey": "Deleting…",
  "created_at": "Registered at: ",
  "last_used": "Last used: ",
  "never": "Never"
}
+7 −1
Changes for panel/src/i18n/resources/en-US/errors.json: 7 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -19,5 +19,11 @@
  "restore_unavailable": "Restore isn't available right now — try again later.",
  "session_expired": "Your session expired — please sign in again.",
  "forbidden": "You are not allowed to do that.",
  "generic": "Something went wrong."
  "generic": "Something went wrong.",
  "otp_resend_cooldown": "Verification code requested too frequently, please try again later.",
  "otp_locked": "Too many incorrect attempts, please request a new verification code.",
  "passkey_challenge_invalid": "Passkey challenge is invalid or expired, please try again.",
  "invalid_attestation": "Could not verify this Passkey, please try again.",
  "passkey_already_bound": "This Passkey is already bound to another account.",
  "passkey_unavailable": "Passkey subsystem is not available right now."
}
+26 −1
Changes for panel/src/i18n/resources/zh-CN/account.json: 26 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -17,5 +17,30 @@
  "link_code": "关联码",
  "link_code_placeholder": "ABCD2345",
  "verify_btn": "关联",
  "verifying": "验证中…"
  "verifying": "验证中…",
  "email_verification": "邮箱验证",
  "email_desc": "验证你的电子邮箱以确保账号安全。",
  "email_verified": "已验证",
  "email_unverified": "未验证",
  "send_code": "获取验证码",
  "sending_code": "发送中…",
  "email_step1": "输入电子邮箱",
  "email_step1_desc": "输入你想要绑定的电子邮箱地址以获取验证码。",
  "email_step2": "输入验证码",
  "email_step2_desc": "输入发送至你邮箱的 6 位数字验证码(10 分钟内有效)。",
  "email_verify_btn": "验证",
  "email_verifying": "验证中…",
  "email_otp_sent": "验证码已发送。",
  "passkeys": "Passkey 注册管理",
  "passkeys_desc": "Passkey 允许你使用指纹、面容或设备 PIN 码安全登录面板。",
  "no_passkeys": "未绑定任何 Passkey。",
  "add_passkey": "注册新 Passkey",
  "passkey_name": "设备昵称",
  "passkey_name_placeholder": "例如:我的手机, YubiKey",
  "registering_passkey": "注册中…",
  "delete_passkey": "删除",
  "deleting_passkey": "删除中…",
  "created_at": "注册时间:",
  "last_used": "上次使用:",
  "never": "从未"
}
Loading