From d105622d7ea48795fe9697b98fa5ecd50987bd5c Mon Sep 17 00:00:00 2001 From: Dhwanksh Katara Date: Wed, 14 Oct 2026 18:31:30 +0000 Subject: [PATCH] feat(portal): add central KA profile settings and account badge --- README.md | 19 +++++++- app/api/account/manage/route.ts | 6 +++ app/api/account/profile/route.ts | 27 +++++++++++ app/dash/account/page.tsx | 4 ++ components/KastrickProfile.module.css | 17 +++++++ components/KastrickProfile.tsx | 69 +++++++++++++++++++++++++++ components/dash-shell.tsx | 5 +- lib/ka-profile.ts | 25 ++++++++++ lib/kastrick-login.ts | 4 +- scripts/kastrick-profile-smoke.cjs | 45 +++++++++++++++++ 10 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 app/api/account/manage/route.ts create mode 100644 app/api/account/profile/route.ts create mode 100644 app/dash/account/page.tsx create mode 100644 components/KastrickProfile.module.css create mode 100644 components/KastrickProfile.tsx create mode 100644 lib/ka-profile.ts create mode 100644 scripts/kastrick-profile-smoke.cjs diff --git a/README.md b/README.md index 4fdc932..204e8cc 100644 --- a/README.md +++ b/README.md @@ -10,4 +10,21 @@ The callback verifies browser state and PKCE, issues the existing HttpOnly porta session cookie, and clears an old RPX cookie when the account changes. RPX browser authorization preserves its local return path through signup/login. Existing UAC proxy settings still support session validation and RPX exchange. Desktop account -selection and the KA dashboard are separate next-task work. +selection uses the backend's separate public device flow. Web login keeps the +credential flow. + +Global profile editing is available at `/dash/account`. `/api/account/profile` +proxies the centrally stored KA name/avatar with the service identity and session +pinned server-side. Profile changes refresh on focus and while visible; drafts +survive errors and concurrent edits require an explicit refresh. The account +navigation badge uses that same profile. `/api/account/manage` opens the configured +central dashboard for service disconnects, account-wide managed storage, and +signout. A portal-only session may need to sign in to the authority first. + +Validation: + +```bash +npm run typecheck +node scripts/kastrick-login-smoke.cjs +node scripts/kastrick-profile-smoke.cjs +``` diff --git a/app/api/account/manage/route.ts b/app/api/account/manage/route.ts new file mode 100644 index 0000000..343767c --- /dev/null +++ b/app/api/account/manage/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from "next/server"; +import { loginConfig } from "@/lib/kastrick-login"; +export function GET() { + try { return NextResponse.redirect(new URL("/account", loginConfig().authority)); } + catch { return NextResponse.json({ ok: false, message: "Kastrick Account management is temporarily unavailable." }, { status: 503 }); } +} diff --git a/app/api/account/profile/route.ts b/app/api/account/profile/route.ts new file mode 100644 index 0000000..32d38ca --- /dev/null +++ b/app/api/account/profile/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { loginApi, DEVELOPER_SESSION_COOKIE } from "@/lib/kastrick-login"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +async function dispatch(request: NextRequest, update: boolean) { + if (update && request.headers.get("origin") !== request.nextUrl.origin) return NextResponse.json({ ok: false, message: "Reload this page before saving." }, { status: 403 }); + const token = request.cookies.get(DEVELOPER_SESSION_COOKIE)?.value; + if (!token) return NextResponse.json({ ok: false, message: "Sign in to edit your Kastrick profile." }, { status: 401 }); + let edits: Record = {}; + if (update) { + try { + const text = await request.text(); if (Buffer.byteLength(text, "utf8") > 2048) throw new Error("large profile"); + const payload = JSON.parse(text); + if (!payload || typeof payload.displayName !== "string" || payload.displayName.length > 160 || typeof payload.avatarTone !== "string" || !Number.isSafeInteger(payload.expectedRevision) || payload.expectedRevision < 0) throw new Error("invalid profile"); + edits = { displayName: payload.displayName, avatarTone: payload.avatarTone, expectedRevision: payload.expectedRevision }; + } catch { return NextResponse.json({ ok: false, message: "Enter a display name and choose an avatar color." }, { status: 400 }); } + } + try { + // Ignore browser-supplied identities/tokens: the service and session come + // exclusively from deployment configuration and the HttpOnly cookie. + const result = await loginApi("profile", { action: update ? "updateProfile" : "getProfile", sessionToken: token, ...edits }); + const message = result.status === 409 ? "Your profile changed in another service. Refresh it before saving; your input has been kept." : result.status === 401 ? "Your session expired. Sign in again." : result.status === 400 ? "Use a name of 1–80 characters and choose an avatar color." : "Kastrick accounts are temporarily unavailable."; + return NextResponse.json(result.ok ? { ok: true, profile: result.body.profile, avatarUrl: result.body.avatarUrl, storage: result.body.storage } : { ok: false, message }, { status: result.ok ? 200 : result.status >= 400 ? result.status : 502, headers: { "Cache-Control": "no-store" } }); + } catch { return NextResponse.json({ ok: false, message: "Kastrick accounts are temporarily unavailable." }, { status: 503 }); } +} +export function GET(request: NextRequest) { return dispatch(request, false); } +export function PATCH(request: NextRequest) { return dispatch(request, true); } diff --git a/app/dash/account/page.tsx b/app/dash/account/page.tsx new file mode 100644 index 0000000..3e85fc2 --- /dev/null +++ b/app/dash/account/page.tsx @@ -0,0 +1,4 @@ +import { KastrickProfileEditor } from "@/components/KastrickProfile"; +export default function AccountPage() { + return
KA · Kastrick Account

Your profile, everywhere.

Profile changes sync with Engine Studio and your connected Kastrick apps.

; +} diff --git a/components/KastrickProfile.module.css b/components/KastrickProfile.module.css new file mode 100644 index 0000000..ddf18c3 --- /dev/null +++ b/components/KastrickProfile.module.css @@ -0,0 +1,17 @@ +.profile { padding: 16px; color: inherit; border: 1px solid var(--border, #51435e); border-radius: 12px; font-size: 12px; } +.profile header { display: flex; gap: 12px; align-items: center; margin-bottom: 20px; } +.profile header img { flex: none; border-radius: 14px; } +.profile h3 { margin: 0 0 5px; font-size: 16px; } +.profile p { margin: 8px 0; line-height: 1.6; overflow-wrap: anywhere; } +.profile header p { font-size: 11px; } +.profile form { display: grid; gap: 9px; } +.profile label { font-weight: 600; margin-top: 4px; } +.profile input, .profile select { min-height: 42px; width: 100%; border: 1px solid var(--border, #51435e); border-radius: 7px; background: var(--input-bg, transparent); color: inherit; padding: 10px; font: inherit; } +.profile select option { background: #1b1525; color: #f6edff; } +.profile button { min-height: 44px; border: 1px solid #c4a7ee; border-radius: 8px; margin-top: 10px; background: #d6c0ff; color: #21132f; font: inherit; font-weight: 650; cursor: pointer; } +.profile button:disabled { opacity: .6; cursor: default; } +.profile a { display: inline-block; min-height: 44px; padding: 14px 0 0; color: inherit; text-decoration: underline; } +.profile :focus-visible { outline: 2px solid #bda1ea; outline-offset: 3px; } +.badge { display: inline-flex; align-items: center; gap: 7px; min-width: 0; } +.badge img { border-radius: 7px; flex: none; } +.badge span { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/components/KastrickProfile.tsx b/components/KastrickProfile.tsx new file mode 100644 index 0000000..1b57ef4 --- /dev/null +++ b/components/KastrickProfile.tsx @@ -0,0 +1,69 @@ +"use client"; +import { useEffect, useId, useRef, useState } from "react"; +import { PROFILE_UPDATED_EVENT, readKastrickProfile, updateKastrickProfile, type KastrickProfileReply } from "@/lib/ka-profile"; +import styles from "./KastrickProfile.module.css"; + +/** Refresh on focus/visibility and while visible, without replacing an owner's + * unsaved draft. The draft retains its original revision for conflict checking. */ +export function KastrickProfileEditor() { + const id = useId(); + const [account, setAccount] = useState(null); + const [name, setName] = useState(""); const [tone, setTone] = useState("violet"); + const [busy, setBusy] = useState(false); const [message, setMessage] = useState("Loading your Kastrick profile…"); + const dirty = useRef(false); const saving = useRef(false); const draftRevision = useRef(0); + const refreshRef = useRef<(force?: boolean) => Promise>(async () => {}); + useEffect(() => { + const controller = new AbortController(); let refreshing = false; + async function refresh(force = false) { + if (saving.current || refreshing) return; + refreshing = true; + try { + const body = await readKastrickProfile(controller.signal); + if (controller.signal.aborted || saving.current) return; + setAccount(body); + if (!dirty.current || force) { setName(body.profile.displayName); setTone(body.profile.avatarTone); draftRevision.current = body.profile.revision; dirty.current = false; setMessage(force ? "Profile refreshed." : ""); } + } catch (error) { if (!controller.signal.aborted) setMessage(error instanceof Error ? error.message : "Couldn’t refresh your profile."); } + finally { refreshing = false; } + } + refreshRef.current = refresh; + const update = () => { if (!document.hidden) void refresh(); }; + void refresh(); window.addEventListener("focus", update); window.addEventListener(PROFILE_UPDATED_EVENT, update); document.addEventListener("visibilitychange", update); + const timer = window.setInterval(update, 30000); + return () => { controller.abort(); window.clearInterval(timer); window.removeEventListener("focus", update); window.removeEventListener(PROFILE_UPDATED_EVENT, update); document.removeEventListener("visibilitychange", update); }; + }, []); + async function save(event: React.FormEvent) { + event.preventDefault(); if (saving.current) return; saving.current = true; setBusy(true); + try { + const body = await updateKastrickProfile(name, tone, draftRevision.current); + setAccount(body); setName(body.profile.displayName); setTone(body.profile.avatarTone); draftRevision.current = body.profile.revision; dirty.current = false; + setMessage("Profile saved across Kastrick. Other services refresh it when you return to them."); window.dispatchEvent(new Event(PROFILE_UPDATED_EVENT)); + } catch (error) { setMessage(error instanceof Error ? error.message : "Couldn’t save your profile."); } + finally { saving.current = false; setBusy(false); } + } + function reload() { + if (dirty.current && !window.confirm("Replace unsaved edits with your current Kastrick profile?")) return; + void refreshRef.current(true); + } + return ( +
+
{account ? Generated account avatar : null}

Kastrick Account

{account?.profile.email || "One profile across Kastrick services."}

+
void save(event)}> + { dirty.current = true; setName(event.target.value); }} maxLength={80} required disabled={busy || !account} autoComplete="nickname" /> + + +
+ {message ?

{message}

: null} + Manage connected services & storage ↗ +
+ ); +} +export function KastrickProfileBadge() { + const [account, setAccount] = useState(null); + useEffect(() => { + const controller = new AbortController(); let refreshing = false; + async function refresh() { if (refreshing || document.hidden) return; refreshing = true; try { const body = await readKastrickProfile(controller.signal); if (!controller.signal.aborted) setAccount(body); } catch { /* The service's existing session UI owns authentication failures. */ } finally { refreshing = false; } } + void refresh(); window.addEventListener("focus", refresh); window.addEventListener(PROFILE_UPDATED_EVENT, refresh); document.addEventListener("visibilitychange", refresh); const timer = window.setInterval(refresh, 30000); + return () => { controller.abort(); window.clearInterval(timer); window.removeEventListener("focus", refresh); window.removeEventListener(PROFILE_UPDATED_EVENT, refresh); document.removeEventListener("visibilitychange", refresh); }; + }, []); + return {account ? : null}{account?.profile.displayName || "Kastrick Account"}; +} diff --git a/components/dash-shell.tsx b/components/dash-shell.tsx index 4504686..45310d0 100644 --- a/components/dash-shell.tsx +++ b/components/dash-shell.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { KastrickProfileBadge } from "@/components/KastrickProfile"; import { ThemeToggle } from "@/components/theme-toggle"; const mainItems = [ @@ -11,7 +12,7 @@ const mainItems = [ ["Upload New Package", "/dash/new-package", "+"], ]; -const reserved = new Set(["packages", "deployments", "new-package"]); +const reserved = new Set(["packages", "deployments", "new-package", "account"]); export function DashShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); @@ -55,7 +56,7 @@ export function DashShell({ children }: { children: React.ReactNode }) {
Developer Console{packageName && / {packageName}}
- + ↗
{children}
diff --git a/lib/ka-profile.ts b/lib/ka-profile.ts new file mode 100644 index 0000000..a530b51 --- /dev/null +++ b/lib/ka-profile.ts @@ -0,0 +1,25 @@ +/** Browser profile requests go to the service server. No session/client token + * crosses this boundary; the shared authority stores the canonical profile. */ +export interface KastrickProfile { + userId: string; + displayName: string; + email: string | null; + avatarSeed: string; + avatarTone: string; + revision: number; +} +export interface KastrickProfileReply { profile: KastrickProfile; avatarUrl: string } +export const PROFILE_UPDATED_EVENT = "kastrick-profile-updated"; +async function profileRequest(init?: RequestInit): Promise { + const response = await fetch("/api/account/profile", { ...init, cache: "no-store" }); + const result = await response.json(); + if (!response.ok || result.ok !== true) throw new Error(result.message || "Couldn’t load your Kastrick profile."); + if (!result.profile || typeof result.profile.displayName !== "string" || !Number.isSafeInteger(result.profile.revision) || typeof result.avatarUrl !== "string") throw new Error("Kastrick returned an invalid profile."); + const avatar = new URL(result.avatarUrl); + if (!["https:", "http:"].includes(avatar.protocol) || avatar.username || avatar.password) throw new Error("Kastrick returned an invalid avatar."); + return { profile: result.profile, avatarUrl: avatar.href }; +} +export function readKastrickProfile(signal?: AbortSignal) { return profileRequest({ signal }); } +export function updateKastrickProfile(displayName: string, avatarTone: string, expectedRevision: number) { + return profileRequest({ method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ displayName, avatarTone, expectedRevision }) }); +} diff --git a/lib/kastrick-login.ts b/lib/kastrick-login.ts index 2e6e380..9d3fa51 100644 --- a/lib/kastrick-login.ts +++ b/lib/kastrick-login.ts @@ -79,12 +79,12 @@ export const transactionCookieOptions = { }; /** Only the service server holds its client credential and PKCE verifier. */ -export async function loginApi(action: "start" | "exchange", body: Record) { +export async function loginApi(action: "start" | "exchange" | "profile", body: Record) { const config = loginConfig(); const response = await fetch(new URL(`/v1/login/${action}`, config.authority), { method: "POST", headers: { Authorization: `Bearer ${config.clientToken}`, "Content-Type": "application/json" }, - body: JSON.stringify({ ...body, serviceId: DEVELOPER_SERVICE_ID, redirectUri: config.redirectUri }), + body: JSON.stringify({ ...body, serviceId: DEVELOPER_SERVICE_ID, ...(action === "profile" ? {} : { redirectUri: config.redirectUri }) }), cache: "no-store", redirect: "error", signal: AbortSignal.timeout(30_000), diff --git a/scripts/kastrick-profile-smoke.cjs b/scripts/kastrick-profile-smoke.cjs new file mode 100644 index 0000000..c404726 --- /dev/null +++ b/scripts/kastrick-profile-smoke.cjs @@ -0,0 +1,45 @@ +// Test the real route handlers with an isolated upstream stub. The short +// browser return code must never become a browser-readable account bearer. +const fs = require("node:fs"); +const path = require("node:path"); +const ts = require("typescript"); +const Module = require("node:module"); +const root = path.resolve(__dirname, ".."); +const originalLoad = Module._load; +Module._load = function (name, parent, isMain) { + if (name === "server-only") return {}; + if (name.startsWith("@/")) name = path.join(root, name.slice(2)); + return originalLoad.call(this, name, parent, isMain); +}; +require.extensions[".ts"] = (module, filename) => { + module._compile(ts.transpileModule(fs.readFileSync(filename, "utf8"), { + fileName: filename, + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, esModuleInterop: true }, + }).outputText, filename); +}; +const assert = require("node:assert/strict"); +const { NextRequest } = require("next/server"); +const { GET, PATCH } = require("../app/api/account/profile/route.ts"); +process.env.KASTRICK_LOGIN_API_URL = "http://localhost:4100"; +process.env.DEVELOPER_PORTAL_LOGIN_REDIRECT_URI = "http://localhost:3100/api/account/callback"; +process.env.DEVELOPER_PORTAL_LOGIN_CLIENT_TOKEN = "test-only-client-credential-with-32-characters"; +const origin = "http://localhost:3100"; +const savedFetch = global.fetch; +let calls = []; +let status = 200; +global.fetch = async (url, init) => { calls.push(JSON.parse(init.body)); return new Response(JSON.stringify(status === 200 ? { ok: true, profile: { displayName: "Ada", revision: 2 }, avatarUrl: "http://localhost:4100/avatars/test" } : { ok: false, error: "profile_changed" }), { status }); }; +async function main() { + assert.equal((await GET(new NextRequest(`${origin}/api/account/profile`))).status, 401); + assert.equal(calls.length, 0); + const headers = { origin, cookie: "rbe_developer_session=server-session", "content-type": "application/json" }; + let response = await PATCH(new NextRequest(`${origin}/api/account/profile`, { method: "PATCH", headers, body: JSON.stringify({ displayName: "Ada", avatarTone: "cyan", expectedRevision: 1, sessionToken: "attacker-session", serviceId: "other", userId: "victim" }) })); + assert.equal(response.status, 200); assert.equal(calls[0].sessionToken, "server-session"); assert.equal(calls[0].serviceId, "developer_portal"); assert.equal(calls[0].userId, undefined); assert.equal(calls[0].expectedRevision, 1); assert.deepEqual(Object.keys(calls[0]).sort(), ["action", "avatarTone", "displayName", "expectedRevision", "serviceId", "sessionToken"].sort()); + calls = []; + response = await PATCH(new NextRequest(`${origin}/api/account/profile`, { method: "PATCH", headers: { ...headers, origin: "https://evil.example" }, body: "{}" })); + assert.equal(response.status, 403); assert.equal(calls.length, 0); + status = 409; + response = await PATCH(new NextRequest(`${origin}/api/account/profile`, { method: "PATCH", headers, body: JSON.stringify({ displayName: "Draft", avatarTone: "rose", expectedRevision: 1 }) })); + assert.equal(response.status, 409); assert.match((await response.json()).message, /changed in another service/); + console.log("KA profile proxy cookie identity, CSRF origin and stale-revision checks passed."); +} +main().catch(error => { console.error(error); process.exitCode = 1; }).finally(() => global.fetch = savedFetch);