Loading internal/api/handlers_user.go +5 −0 Changes for internal/api/handlers_user.go: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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. Loading panel/dev/mockApi.ts +84 −4 Changes for panel/dev/mockApi.ts: 84 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -30,6 +30,7 @@ interface MockAccount { email: string; linked: boolean; mustChangePassword: boolean; emailVerified: boolean; } interface MockServer extends ServerInfo { Loading @@ -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 Loading Loading @@ -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" }, Loading Loading @@ -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: [], }, }; } Loading Loading @@ -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`, }; } Loading Loading @@ -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, }; } Loading Loading @@ -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); } Loading panel/src/i18n/resources/en-US/account.json +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 Loading @@ -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" } panel/src/i18n/resources/en-US/errors.json +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 Loading @@ -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." } panel/src/i18n/resources/zh-CN/account.json +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 Loading @@ -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
internal/api/handlers_user.go +5 −0 Changes for internal/api/handlers_user.go: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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. Loading
panel/dev/mockApi.ts +84 −4 Changes for panel/dev/mockApi.ts: 84 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -30,6 +30,7 @@ interface MockAccount { email: string; linked: boolean; mustChangePassword: boolean; emailVerified: boolean; } interface MockServer extends ServerInfo { Loading @@ -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 Loading Loading @@ -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" }, Loading Loading @@ -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: [], }, }; } Loading Loading @@ -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`, }; } Loading Loading @@ -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, }; } Loading Loading @@ -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); } Loading
panel/src/i18n/resources/en-US/account.json +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 Loading @@ -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" }
panel/src/i18n/resources/en-US/errors.json +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 Loading @@ -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." }
panel/src/i18n/resources/zh-CN/account.json +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 Loading @@ -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": "从未" }