diff --git a/packages/kilo-console/src/client.ts b/packages/kilo-console/src/client.ts index 076b5f4a88..b1e74cadc1 100644 --- a/packages/kilo-console/src/client.ts +++ b/packages/kilo-console/src/client.ts @@ -11,6 +11,7 @@ import type { GlobalHealthResponse, GlobalEvent, KiloEmbeddingModelCatalog, + KiloProfileResponse, LspStatusResponse, McpStatusResponse, Pty as PtyInfo, @@ -42,6 +43,8 @@ export type Query = { export type ProjectQuery = Pick +export type KiloProfileData = KiloProfileResponse + export type ProjectItem = KiloProject export type RecentProjectItem = ProjectItem & { sessions: number @@ -210,6 +213,11 @@ function model(input: unknown) { return { provider: input.slice(0, index), model: input.slice(index + 1) } } +function directory(input: ProjectQuery) { + const dir = value(input.dir) + return dir ? { directory: dir } : undefined +} + function title(input: string) { return input .split("_") @@ -407,6 +415,42 @@ export async function loadEmbeddingModels(input: Query): Promise { + const sdk = client(input) + const result = await sdk.kilo.profile(directory(input)) + return demand("Kilo profile", result) +} + +export async function setKiloOrganization(input: ProjectQuery, organizationId: string | null) { + const sdk = client(input) + const result = await sdk.kilo.organization.set({ ...directory(input), organizationId }) + demand("Switch Kilo account", result) + await sdk.global.dispose() +} + +export async function logoutKilo(input: ProjectQuery) { + const sdk = client(input) + const result = await sdk.auth.remove({ providerID: "kilo" }) + demand("Log out of Kilo", result) + await sdk.global.dispose() +} + +export async function startKiloLogin(input: ProjectQuery): Promise { + const sdk = client(input) + const result = await sdk.provider.oauth.authorize({ ...directory(input), providerID: "kilo", method: 0 }) + return demand("Start Kilo login", result) +} + +export async function completeKiloLogin(input: ProjectQuery, signal?: AbortSignal) { + const sdk = client(input) + const result = await sdk.provider.oauth.callback( + { ...directory(input), providerID: "kilo", method: 0 }, + signal ? { signal } : undefined, + ) + demand("Complete Kilo login", result) + await sdk.global.dispose() +} + export async function loadProjects(input: ProjectQuery): Promise { const sdk = client(input) const dir = value(input.dir) diff --git a/packages/kilo-console/src/index.tsx b/packages/kilo-console/src/index.tsx index 786f9c9cad..13036c76ff 100644 --- a/packages/kilo-console/src/index.tsx +++ b/packages/kilo-console/src/index.tsx @@ -6,6 +6,7 @@ import "./styles.css" import { ProjectConsoleRoute } from "./routes/projects/ProjectConsoleRoute" import { ProjectsRoute } from "./routes/projects/ProjectsRoute" import { ProfileRoute } from "./routes/profile/ProfileRoute" +import { LoginRoute } from "./routes/profile/LoginRoute" import { ConfigLayout } from "./layouts/ConfigLayout" import { configSections } from "./routes/config/sections" @@ -27,6 +28,7 @@ render( {routes()} + {routes()} diff --git a/packages/kilo-console/src/routes/profile/LoginRoute.tsx b/packages/kilo-console/src/routes/profile/LoginRoute.tsx new file mode 100644 index 0000000000..3fde3f6567 --- /dev/null +++ b/packages/kilo-console/src/routes/profile/LoginRoute.tsx @@ -0,0 +1,244 @@ +import { useLocation, useNavigate } from "@solidjs/router" +import { Button } from "@kilocode/kilo-web-ui/button" +import { Card } from "@kilocode/kilo-web-ui/card" +import { createEffect, createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" +import { LoadingScreen } from "../../components/LoadingScreen" +import { completeKiloLogin, loadKiloProfile, startKiloLogin, type ProjectQuery } from "../../client" +import { errMsg } from "../../shared/utils" +import { markDisconnected, page, parseDeviceCode, safeReturn } from "./profile-utils" +import { useProfileServer } from "./server" + +type Status = "idle" | "initiating" | "pending" | "success" | "error" | "cancelled" + +type State = { + status: Status + code?: string + url?: string + expiresIn?: number + error?: string +} + +function time(input: number) { + const min = Math.floor(input / 60) + const sec = input % 60 + return `${min}:${sec.toString().padStart(2, "0")}` +} + +export function LoginRoute() { + const loc = useLocation() + const nav = useNavigate() + const params = createMemo(() => new URLSearchParams(loc.search)) + const server = useProfileServer(params) + const [auth, setAuth] = createSignal({ status: "idle" }) + const [left, setLeft] = createSignal(900) + const [attempt, setAttempt] = createSignal(0) + const [active, setActive] = createSignal() + const [copied, setCopied] = createSignal("") + const ret = () => safeReturn(params().get("return")) + const profile = () => page(params(), "/profile") + + function abort() { + active()?.abort() + setActive(undefined) + } + + function done() { + window.setTimeout(() => nav(ret(), { replace: true }), 650) + } + + function start(input: ProjectQuery | undefined = server.query()) { + if (!input) return + abort() + const ctl = new AbortController() + const rev = attempt() + 1 + setAttempt(rev) + setActive(ctl) + setAuth({ status: "initiating" }) + void startKiloLogin(input) + .then((info) => { + if (attempt() !== rev) return false + setAuth({ + status: "pending", + code: parseDeviceCode(info.instructions), + url: info.url, + expiresIn: 900, + }) + return completeKiloLogin(input, ctl.signal).then(() => true) + }) + .then((ok) => { + if (!ok || attempt() !== rev) return + setAuth({ status: "success" }) + void loadKiloProfile(input) + .then(() => markDisconnected(false)) + .catch(() => markDisconnected(false)) + .finally(done) + }) + .catch((err) => { + if (attempt() !== rev) return + setAuth({ status: "error", error: errMsg(err) }) + }) + .finally(() => { + if (active() === ctl) setActive(undefined) + }) + } + + function cancel() { + abort() + setAttempt((value) => value + 1) + setAuth({ status: "cancelled" }) + } + + function copy(label: string, value: string | undefined) { + if (!value) return + void navigator.clipboard + .writeText(value) + .then(() => { + setCopied(label) + window.setTimeout(() => { + if (copied() === label) setCopied("") + }, 1400) + }) + .catch((err) => setAuth({ status: "error", error: errMsg(err) })) + } + + function open() { + const url = auth().url + if (!url) return + window.open(url, "_blank", "noopener,noreferrer") + } + + createEffect(() => { + const current = server.query() + if (!current || auth().status !== "idle") return + start(current) + }) + + createEffect(() => { + const state = auth() + if (state.status !== "pending") return + setLeft(state.expiresIn ?? 900) + const timer = window.setInterval(() => setLeft((value) => Math.max(0, value - 1)), 1000) + onCleanup(() => window.clearInterval(timer)) + }) + + createEffect(() => { + if (auth().status === "success") server.remember() + }) + + onCleanup(() => { + abort() + setAttempt((value) => value + 1) + }) + + return ( +
+ +
+ ) +} diff --git a/packages/kilo-console/src/routes/profile/ProfileRoute.tsx b/packages/kilo-console/src/routes/profile/ProfileRoute.tsx index 90d20f8a16..71a74ac6aa 100644 --- a/packages/kilo-console/src/routes/profile/ProfileRoute.tsx +++ b/packages/kilo-console/src/routes/profile/ProfileRoute.tsx @@ -1,9 +1,315 @@ +import { A, useLocation, useNavigate } from "@solidjs/router" +import { Button } from "@kilocode/kilo-web-ui/button" +import { Card } from "@kilocode/kilo-web-ui/card" +import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js" +import { LoadingScreen } from "../../components/LoadingScreen" +import { loadKiloProfile, logoutKilo, setKiloOrganization, type KiloProfileData, type ProjectQuery } from "../../client" +import { errMsg } from "../../shared/utils" +import { + authError, + cloud, + credits, + initials, + markDisconnected, + money, + page, + personal, + usage, + wasDisconnected, +} from "./profile-utils" +import { useProfileServer } from "./server" + +type Org = NonNullable[number] +type State = { kind: "connected"; data: KiloProfileData } | { kind: "disconnected" } + +async function load(input: ProjectQuery): Promise { + if (wasDisconnected()) return { kind: "disconnected" } + try { + const data = await loadKiloProfile(input) + markDisconnected(false) + return { kind: "connected", data } + } catch (err) { + if (authError(err)) { + markDisconnected(true) + return { kind: "disconnected" } + } + throw err + } +} + +function org(data: KiloProfileData | undefined) { + if (!data?.currentOrgId) return undefined + return data.profile.organizations?.find((item) => item.id === data.currentOrgId) +} + +function account(data: KiloProfileData | undefined) { + return org(data)?.name ?? "Personal Account" +} + +function role(input: Org) { + if (input.role === "owner") return "Owner" + if (input.role === "admin") return "Admin" + if (input.role === "billing_manager") return "Billing" + return "Member" +} + export function ProfileRoute() { + const loc = useLocation() + const nav = useNavigate() + const params = createMemo(() => new URLSearchParams(loc.search)) + const server = useProfileServer(params) + const [data, actions] = createResource(server.query, load) + const [saving, setSaving] = createSignal() + const [error, setError] = createSignal("") + const profile = createMemo(() => { + const state = data() + if (state?.kind === "connected") return state.data + return undefined + }) + const orgs = createMemo(() => profile()?.profile.organizations ?? []) + const active = createMemo(() => org(profile())) + const disconnected = createMemo(() => data()?.kind === "disconnected") + const scope = createMemo(() => { + if (profile()) return account(profile()) + if (data.loading) return "Loading..." + return "Not connected" + }) + const login = () => page(params(), "/kilo/login") + const overview = () => page(params(), "/profile") + const usageUrl = createMemo(() => usage(profile()?.currentOrgId)) + const creditsUrl = createMemo(() => credits(profile()?.currentOrgId)) + + createEffect(() => { + if (profile()) server.remember() + }) + + createEffect(() => { + const err = data.error + if (!err || authError(err)) return + server.recover() + }) + + function refresh() { + setError("") + void actions.refetch() + } + + function choose(id: string | null) { + const current = server.query() + if (!current) return + const next = id ?? personal + const selected = profile()?.currentOrgId ?? null + if (selected === id) return + setSaving(next) + setError("") + void setKiloOrganization(current, id) + .then(() => actions.refetch()) + .catch((err) => setError(errMsg(err))) + .finally(() => setSaving(undefined)) + } + + function logout() { + const current = server.query() + if (!current) return + setSaving("logout") + setError("") + void logoutKilo(current) + .then(() => { + markDisconnected(true) + actions.mutate({ kind: "disconnected" }) + nav(overview(), { replace: true }) + }) + .catch((err) => setError(errMsg(err))) + .finally(() => setSaving(undefined)) + } + return ( -
-

Profile

-

Profile Settings

-

Profile identity, account preferences, and workspace defaults will live here.

+
+ + + +
+
+ +
+
+

Kilo Account

+

Your Profile

+

Manage your Kilo identity, account context, credits, and billing shortcuts.

+
+
+ + + Open Dashboard + +
+
+
+ + + + + + + + + + + + +
+

Kilo Account

+

Connect your Kilo account

+

Sign in to view your credits, organizations, and account details in Kilo Console.

+
+ + Connect + +
+
+ + + + Kilo server not found + Start Kilo Console from a running Kilo server or pass a server URL with ?server=. + + + + + + Profile request failed + {errMsg(data.error)} + + + + + + Account update failed + {error()} + + + + + {(info) => ( +
+ + + + Remaining Credits + {money(info().balance?.balance)} +

+ Credits shown for {account(info())}. Switch accounts below to see organization balances when + available. +

+
+ + +
+
+ Organizations +

Your Accounts

+
+ {orgs().length === 1 ? "1 org" : `${orgs().length} orgs`} +
+
+ + + {(item) => ( + + )} + +
+
+
+ )} +
+
+
) } diff --git a/packages/kilo-console/src/routes/profile/profile-utils.test.ts b/packages/kilo-console/src/routes/profile/profile-utils.test.ts new file mode 100644 index 0000000000..95ed86a0e9 --- /dev/null +++ b/packages/kilo-console/src/routes/profile/profile-utils.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test" +import { authError, credits, initials, money, page, parseDeviceCode, safeReturn, usage } from "./profile-utils" + +test("parses device auth codes from instructions", () => { + expect(parseDeviceCode("Open https://app.kilo.ai/device-auth and enter code: ABCD-2345")).toBe("ABCD-2345") + expect(parseDeviceCode("Use ABCD-2345 to continue")).toBe("ABCD-2345") + expect(parseDeviceCode(undefined)).toBeUndefined() +}) + +test("detects Kilo auth failures", () => { + expect(authError(new Error("Kilo profile: Unauthorized"))).toBe(true) + expect(authError({ status: 401 })).toBe(true) + expect(authError(new Error("Kilo profile: temporary network failure"))).toBe(false) +}) + +test("formats account display helpers", () => { + expect(money(12.5)).toBe("$12.50") + expect(money(null)).toBe("Unknown") + expect(initials("Jane Developer", "jane@example.com")).toBe("JD") + expect(initials("", "solo@example.com")).toBe("SE") +}) + +test("keeps login returns internal", () => { + expect(safeReturn("/profile?server=http%3A%2F%2F127.0.0.1%3A4097")).toBe( + "/profile?server=http%3A%2F%2F127.0.0.1%3A4097", + ) + expect(safeReturn("https://app.kilo.ai/profile")).toBe("/profile") + expect(safeReturn("//app.kilo.ai/profile")).toBe("/profile") +}) + +test("builds local links while preserving server", () => { + const params = new URLSearchParams({ server: "http://127.0.0.1:4097", ignored: "1" }) + expect(page(params, "/kilo/login", { return: "/profile" })).toBe( + "/kilo/login?server=http%3A%2F%2F127.0.0.1%3A4097&return=%2Fprofile", + ) +}) + +test("builds account-aware cloud links", () => { + const id = "9d4b144c-0a2b-477b-973d-24fa02bebf13" + expect(usage(null)).toBe("https://app.kilo.ai/usage") + expect(usage(id)).toBe(`https://app.kilo.ai/organizations/${id}/usage-details`) + expect(credits(undefined)).toBe("https://app.kilo.ai/profile") + expect(credits(id)).toBe(`https://app.kilo.ai/organizations/${id}`) +}) diff --git a/packages/kilo-console/src/routes/profile/profile-utils.ts b/packages/kilo-console/src/routes/profile/profile-utils.ts new file mode 100644 index 0000000000..b61e6e0347 --- /dev/null +++ b/packages/kilo-console/src/routes/profile/profile-utils.ts @@ -0,0 +1,80 @@ +export const personal = "personal" + +let marked = false + +export function markDisconnected(input: boolean) { + marked = input +} + +export function wasDisconnected() { + return marked +} + +function text(input: unknown) { + if (input instanceof Error) return input.message + if (typeof input === "string") return input + if (input === undefined || input === null) return "" + return JSON.stringify(input) +} + +export function parseDeviceCode(input: string | undefined) { + if (!input) return undefined + const code = input.match(/code:\s*([A-Z0-9-]+)/i)?.[1] + if (code) return code.toUpperCase() + return input.match(/\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/i)?.[1]?.toUpperCase() +} + +export function authError(input: unknown) { + const value = text(input).toLowerCase() + return value.includes("unauthorized") || value.includes("invalid token") || value.includes('status":401') +} + +export function money(input: number | null | undefined) { + if (typeof input !== "number" || !Number.isFinite(input)) return "Unknown" + return `$${input.toFixed(2)}` +} + +export function initials(name: string | undefined, email: string) { + const parts = (name?.trim() || email) + .split(/[\s._@-]+/) + .filter(Boolean) + .slice(0, 2) + const value = parts.map((part) => part[0] ?? "").join("") + return value.toUpperCase() || "KG" +} + +export function safeReturn(input: string | null | undefined) { + if (!input || !input.startsWith("/") || input.startsWith("//")) return "/profile" + try { + const url = new URL(input, "http://localhost") + if (url.origin !== "http://localhost") return "/profile" + return `${url.pathname}${url.search}${url.hash}` + } catch { + return "/profile" + } +} + +export function page(params: URLSearchParams, path: string, extra?: Record) { + const next = new URLSearchParams() + const server = params.get("server") + if (server) next.set("server", server) + for (const [key, value] of Object.entries(extra ?? {})) { + if (value) next.set(key, value) + } + const query = next.toString() + return `${path}${query ? `?${query}` : ""}` +} + +export function cloud(path = "/profile") { + return `https://app.kilo.ai${path}` +} + +export function usage(id: string | null | undefined) { + if (!id) return cloud("/usage") + return cloud(`/organizations/${encodeURIComponent(id)}/usage-details`) +} + +export function credits(id: string | null | undefined) { + if (!id) return cloud("/profile") + return cloud(`/organizations/${encodeURIComponent(id)}`) +} diff --git a/packages/kilo-console/src/routes/profile/server.ts b/packages/kilo-console/src/routes/profile/server.ts new file mode 100644 index 0000000000..82c748a618 --- /dev/null +++ b/packages/kilo-console/src/routes/profile/server.ts @@ -0,0 +1,71 @@ +import { createEffect, createMemo, createSignal } from "solid-js" +import { + discover, + forgetCached, + loadCached, + resolveServer, + saveCached, + type ProjectQuery, +} from "../../client" +import { clean } from "../../shared/utils" + +const ports = new Set(["3017", "3018"]) + +function shouldDiscover(input: URLSearchParams) { + if (input.get("server")) return false + return ports.has(window.location.port) +} + +function base(input: URLSearchParams) { + const param = input.get("server") + if (param) return param + const cached = shouldDiscover(input) ? loadCached() : "" + if (cached) return cached + if (shouldDiscover(input)) return "" + return window.location.origin +} + +export function useProfileServer(params: () => URLSearchParams) { + const [url, setUrl] = createSignal(base(params())) + const discoverable = () => shouldDiscover(params()) + const query = createMemo(() => { + const target = clean(url()) || base(params()) + if (!target) return undefined + return { url: target, dir: "" } + }) + + createEffect(() => { + const next = params().get("server") + if (next && next !== url()) setUrl(next) + }) + + createEffect(() => { + if (!discoverable()) return + void resolveServer().then((value) => { + if (!value) return + saveCached(value) + setUrl(value) + }) + }) + + function remember() { + const current = query() + if (!current || !discoverable()) return + saveCached(current.url) + } + + function recover() { + if (!discoverable()) return + const cached = loadCached() + if (!cached || cached !== url()) return + forgetCached() + setUrl("") + void discover().then((value) => { + if (!value) return + saveCached(value) + setUrl(value) + }) + } + + return { query, discoverable, remember, recover } +} diff --git a/packages/kilo-console/src/shared/navigation.test.ts b/packages/kilo-console/src/shared/navigation.test.ts index 01f36705c1..53f6682cea 100644 --- a/packages/kilo-console/src/shared/navigation.test.ts +++ b/packages/kilo-console/src/shared/navigation.test.ts @@ -11,6 +11,7 @@ test("classifies routes after stripping the console base", () => { expect(path("/console/projects/demo/settings/agents", "/console")).toBe("/project") expect(path("/console/settings/agents", "/console")).toBe("/settings") expect(path("/console/profile", "/console")).toBe("/profile") + expect(path("/console/kilo/login", "/console")).toBe("/profile") }) test("builds settings roots without preserving the console base", () => { diff --git a/packages/kilo-console/src/shared/navigation.ts b/packages/kilo-console/src/shared/navigation.ts index 3d1476eebe..195f491a0a 100644 --- a/packages/kilo-console/src/shared/navigation.ts +++ b/packages/kilo-console/src/shared/navigation.ts @@ -21,7 +21,7 @@ export function settings(input: string, prefix = base()) { export function path(input: string, prefix = base()): Path { const route = strip(input, prefix) - if (route === "/profile") return "/profile" + if (route === "/profile" || route.startsWith("/kilo/login")) return "/profile" if (route.startsWith("/settings") || route.startsWith("/config")) return "/settings" if (route.startsWith("/projects/")) return "/project" return "/projects" diff --git a/packages/kilo-console/src/styles.css b/packages/kilo-console/src/styles.css index 5ab4a61da3..bad09eb3ec 100644 --- a/packages/kilo-console/src/styles.css +++ b/packages/kilo-console/src/styles.css @@ -14,6 +14,7 @@ @import "./styles/agents-tools.css"; @import "./styles/projects.css"; @import "./styles/project-console.css"; +@import "./styles/profile.css"; @import "./styles/servers.css"; @import "./styles/sources.css"; @import "./styles/responsive.css"; diff --git a/packages/kilo-console/src/styles/profile.css b/packages/kilo-console/src/styles/profile.css new file mode 100644 index 0000000000..c5630cfa28 --- /dev/null +++ b/packages/kilo-console/src/styles/profile.css @@ -0,0 +1,480 @@ +.kilo-console .profile-page, +.kilo-console .profile-login-page { + display: grid; + gap: 1rem; + max-width: 64rem; + margin: 0 auto; +} + +.kilo-console .profile-login-page { + max-width: 34rem; +} + +.kilo-console .profile-shell.disconnected { + grid-template-columns: minmax(0, 1fr); +} + +.kilo-console .profile-header, +.kilo-console .profile-login-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + align-items: start; + padding-bottom: 0.5rem; +} + +.kilo-console .profile-login-header { + grid-template-columns: 1fr; + text-align: center; +} + +.kilo-console .profile-header h1, +.kilo-console .profile-login-header h1 { + margin: 0; + color: var(--foreground); + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.02em; +} + +.kilo-console .profile-header p:not(.eyebrow), +.kilo-console .profile-login-header p:not(.eyebrow) { + max-width: 42rem; + margin: 0.625rem 0 0; + color: var(--muted-foreground); + font-size: 0.8125rem; + line-height: 1.45; +} + +.kilo-console .profile-actions, +.kilo-console .profile-card-actions, +.kilo-console .profile-login-actions { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; +} + +.kilo-console .profile-primary-link { + text-decoration: none; +} + +.kilo-console .profile-connect-card { + display: grid; + min-height: 18rem; + place-items: center; + align-content: center; + gap: 1.25rem; + text-align: center; +} + +.kilo-console .profile-connect-mark { + display: grid; + width: 3.5rem; + height: 3.5rem; + place-items: center; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: + linear-gradient(135deg, color-mix(in oklab, var(--primary) 32%, transparent), transparent), + color-mix(in oklab, var(--muted) 70%, transparent); + color: var(--foreground); + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.08em; +} + +.kilo-console .profile-connect-copy { + display: grid; + max-width: 28rem; + gap: 0.625rem; +} + +.kilo-console .profile-connect-copy h1, +.kilo-console .profile-connect-copy p { + margin: 0; +} + +.kilo-console .profile-connect-copy h1 { + color: var(--foreground); + font-size: 1.125rem; + font-weight: 600; + letter-spacing: -0.02em; +} + +.kilo-console .profile-connect-copy p:not(.eyebrow) { + color: var(--muted-foreground); + font-size: 0.8125rem; + line-height: 1.5; +} + +.kilo-console .profile-link-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 1.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--input-base); + color: var(--foreground); + font-size: 0.75rem; + font-weight: 500; + line-height: 1.45; + padding: 0.25rem 0.625rem; + text-decoration: none; + transition: + background 120ms ease, + border-color 120ms ease, + color 120ms ease; +} + +.kilo-console .profile-link-button:hover, +.kilo-console .profile-link-button:focus-visible { + border-color: var(--ring); + background: var(--muted); + color: var(--foreground); + outline: 0; +} + +.kilo-console .profile-banner { + margin-bottom: 0; +} + +.kilo-console .profile-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.kilo-console .profile-card { + gap: 0.875rem; +} + +.kilo-console .profile-wide-card { + grid-column: 1 / -1; +} + +.kilo-console .profile-account-card, +.kilo-console .profile-balance-card { + min-height: 10rem; +} + +.kilo-console .profile-account-head { + display: flex; + gap: 0.875rem; + align-items: center; + min-width: 0; +} + +.kilo-console .profile-avatar, +.kilo-console .profile-org-icon { + display: grid; + flex: 0 0 auto; + place-items: center; + border: 1px solid var(--border); + background: + linear-gradient(135deg, color-mix(in oklab, var(--primary) 28%, transparent), transparent), + color-mix(in oklab, var(--muted) 70%, transparent); + color: var(--foreground); + font-weight: 700; + letter-spacing: 0.06em; +} + +.kilo-console .profile-avatar { + width: 3.25rem; + height: 3.25rem; + border-radius: var(--radius-lg); + font-size: 1rem; +} + +.kilo-console .profile-org-icon { + width: 2rem; + height: 2rem; + border-radius: var(--radius-sm); + font-size: 0.6875rem; +} + +.kilo-console .profile-account-text { + display: grid; + min-width: 0; + gap: 0.125rem; +} + +.kilo-console .profile-account-text strong, +.kilo-console .profile-card-head h2, +.kilo-console .profile-login-state strong, +.kilo-console .profile-login-step strong { + margin: 0; + color: var(--foreground); + font-size: 0.9375rem; + font-weight: 600; + line-height: 1.25; +} + +.kilo-console .profile-account-text span, +.kilo-console .profile-login-state p, +.kilo-console .profile-login-step > span, +.kilo-console .profile-login-waiting { + color: var(--muted-foreground); + font-size: 0.75rem; + line-height: 1.45; +} + +.kilo-console .profile-account-text span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kilo-console .profile-meta-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: color-mix(in oklab, var(--muted) 35%, transparent); + padding: 0.75rem; +} + +.kilo-console .profile-meta-row span, +.kilo-console .profile-card-label { + color: var(--muted-foreground); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.kilo-console .profile-meta-row strong { + color: var(--foreground); + font-size: 0.75rem; + font-weight: 600; +} + +.kilo-console .profile-balance-card strong { + color: var(--foreground); + font-size: clamp(2rem, 6vw, 3.25rem); + font-weight: 700; + letter-spacing: -0.05em; + line-height: 0.95; +} + +.kilo-console .profile-balance-card p, +.kilo-console .profile-login-state p { + margin: 0; +} + +.kilo-console .profile-card-head { + display: flex; + gap: 1rem; + align-items: flex-start; + justify-content: space-between; +} + +.kilo-console .profile-pill { + display: inline-flex; + align-items: center; + min-height: 1.5rem; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted-foreground); + font-size: 0.6875rem; + font-weight: 600; + padding: 0 0.625rem; + text-decoration: none; + text-transform: uppercase; +} + +.kilo-console .profile-org-list { + display: grid; + gap: 0.5rem; +} + +.kilo-console .profile-org-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + width: 100%; + min-height: 3.5rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); + color: var(--foreground); + cursor: pointer; + font: inherit; + padding: 0.75rem; + text-align: left; + transition: + background 120ms ease, + border-color 120ms ease; +} + +.kilo-console .profile-org-row:hover, +.kilo-console .profile-org-row:focus-visible, +.kilo-console .profile-org-row.active { + border-color: var(--ring); + background: color-mix(in oklab, var(--muted) 45%, transparent); + outline: 0; +} + +.kilo-console .profile-org-row:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.kilo-console .profile-org-body { + display: grid; + min-width: 0; + gap: 0.125rem; +} + +.kilo-console .profile-org-body strong { + overflow: hidden; + color: var(--foreground); + font-size: 0.8125rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kilo-console .profile-org-body span, +.kilo-console .profile-org-state { + color: var(--muted-foreground); + font-size: 0.6875rem; +} + +.kilo-console .profile-org-state { + font-weight: 600; +} + +.kilo-console .profile-login-card { + min-height: 18rem; +} + +.kilo-console .profile-login-state, +.kilo-console .profile-login-flow, +.kilo-console .profile-login-step { + display: grid; + gap: 0.875rem; +} + +.kilo-console .profile-login-state { + place-items: center; + min-height: 14rem; + text-align: center; +} + +.kilo-console .profile-login-state.error strong { + color: var(--destructive); +} + +.kilo-console .profile-login-state.success strong { + color: var(--success, var(--primary)); +} + +.kilo-console .profile-login-step { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: color-mix(in oklab, var(--muted) 25%, transparent); + padding: 1rem; +} + +.kilo-console .profile-login-url { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; +} + +.kilo-console .profile-login-url span { + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--input-base); + color: var(--foreground); + font-family: var(--font-family-mono, monospace); + font-size: 0.75rem; + padding: 0.5rem 0.625rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kilo-console .profile-login-code { + display: grid; + gap: 0.25rem; + place-items: center; + border: 1px solid var(--ring); + border-radius: var(--radius-lg); + background: color-mix(in oklab, var(--primary) 12%, var(--input-base)); + color: var(--foreground); + cursor: pointer; + font-family: var(--font-family-mono, monospace); + font-size: 2rem; + font-weight: 700; + letter-spacing: 0.16em; + padding: 1rem; +} + +.kilo-console .profile-login-code small { + color: var(--muted-foreground); + font-family: var(--font-family-sans, system-ui, sans-serif); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0; +} + +.kilo-console .profile-login-waiting { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.kilo-console .profile-spinner { + width: 1rem; + height: 1rem; + border: 2px solid color-mix(in oklab, var(--muted-foreground) 30%, transparent); + border-top-color: var(--primary); + border-radius: 999px; + animation: profile-spin 900ms linear infinite; +} + +@keyframes profile-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 760px) { + .kilo-console .profile-header, + .kilo-console .profile-grid { + grid-template-columns: 1fr; + } + + .kilo-console .profile-actions, + .kilo-console .profile-card-actions, + .kilo-console .profile-login-actions { + justify-content: flex-start; + } + + .kilo-console .profile-login-url { + grid-template-columns: 1fr; + } +} + +@media (max-width: 520px) { + .kilo-console .profile-org-row { + grid-template-columns: auto minmax(0, 1fr); + } + + .kilo-console .profile-org-state { + grid-column: 2; + } + + .kilo-console .profile-login-code { + font-size: 1.5rem; + } +}