mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #11434 from Kilo-Org/zest-canoe
feat: add account profile to Kilo Console
This commit is contained in:
@@ -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<Query, "url" | "dir">
|
||||
|
||||
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<KiloEmbeddingMo
|
||||
return demand("Kilo embedding models", await client(input).indexing.models())
|
||||
}
|
||||
|
||||
export async function loadKiloProfile(input: ProjectQuery): Promise<KiloProfileData> {
|
||||
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<ProviderAuthAuthorization> {
|
||||
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<ProjectItem[]> {
|
||||
const sdk = client(input)
|
||||
const dir = value(input.dir)
|
||||
|
||||
@@ -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()}
|
||||
</Route>
|
||||
<Route path="/profile" component={ProfileRoute} />
|
||||
<Route path="/kilo/login" component={LoginRoute} />
|
||||
<Route path="/settings" component={ConfigLayout}>
|
||||
{routes()}
|
||||
</Route>
|
||||
|
||||
@@ -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<State>({ status: "idle" })
|
||||
const [left, setLeft] = createSignal(900)
|
||||
const [attempt, setAttempt] = createSignal(0)
|
||||
const [active, setActive] = createSignal<AbortController>()
|
||||
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 (
|
||||
<section class="route-empty">
|
||||
<div class="profile-login-page">
|
||||
<header class="profile-login-header">
|
||||
<p class="eyebrow">Kilo Login</p>
|
||||
<h1>Login to Kilo</h1>
|
||||
<p>Authorize this Kilo Console through the same device auth flow used by the editor clients.</p>
|
||||
</header>
|
||||
|
||||
<Show when={!server.query() && server.discoverable()}>
|
||||
<LoadingScreen variant="fullscreen" />
|
||||
</Show>
|
||||
|
||||
<Show when={!server.query() && !server.discoverable()}>
|
||||
<Card class="profile-login-card" variant="warning">
|
||||
<strong>Kilo server not found</strong>
|
||||
<p>Start a local Kilo server or pass a server URL with ?server=.</p>
|
||||
<a class="profile-link-button" href={profile()}>
|
||||
Back to Profile
|
||||
</a>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show when={server.query()}>
|
||||
<Card class="profile-login-card">
|
||||
<Switch>
|
||||
<Match when={auth().status === "idle" || auth().status === "initiating"}>
|
||||
<div class="profile-login-state">
|
||||
<span class="profile-spinner" aria-hidden="true" />
|
||||
<strong>Starting login...</strong>
|
||||
<p>Preparing a secure browser authorization request.</p>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={auth().status === "pending"}>
|
||||
<div class="profile-login-flow">
|
||||
<div class="profile-login-step">
|
||||
<span>Step 1</span>
|
||||
<strong>Open this URL</strong>
|
||||
<div class="profile-login-url">
|
||||
<span>{auth().url}</span>
|
||||
<Button variant="secondary" type="button" onClick={() => copy("url", auth().url)}>
|
||||
{copied() === "url" ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="primary" type="button" onClick={open}>
|
||||
Open Browser
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show when={auth().code}>
|
||||
{(code) => (
|
||||
<div class="profile-login-step">
|
||||
<span>Step 2</span>
|
||||
<strong>Enter this code</strong>
|
||||
<button type="button" class="profile-login-code" onClick={() => copy("code", code())}>
|
||||
{code()}
|
||||
<small>{copied() === "code" ? "Copied" : "Click to copy"}</small>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<div class="profile-login-waiting">
|
||||
<span class="profile-spinner" aria-hidden="true" />
|
||||
<span>Waiting for authorization ({time(left())})</span>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" type="button" onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={auth().status === "success"}>
|
||||
<div class="profile-login-state success">
|
||||
<strong>Login successful</strong>
|
||||
<p>Redirecting back to your profile.</p>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={auth().status === "error"}>
|
||||
<div class="profile-login-state error">
|
||||
<strong>Login failed</strong>
|
||||
<p>{auth().error}</p>
|
||||
<div class="profile-login-actions">
|
||||
<Button variant="primary" type="button" onClick={() => start()}>
|
||||
Try Again
|
||||
</Button>
|
||||
<a class="profile-link-button" href={profile()}>
|
||||
Back to Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={auth().status === "cancelled"}>
|
||||
<div class="profile-login-state">
|
||||
<strong>Login cancelled</strong>
|
||||
<p>No credentials were saved.</p>
|
||||
<Button variant="primary" type="button" onClick={() => start()}>
|
||||
Start Again
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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<KiloProfileData["profile"]["organizations"]>[number]
|
||||
type State = { kind: "connected"; data: KiloProfileData } | { kind: "disconnected" }
|
||||
|
||||
async function load(input: ProjectQuery): Promise<State> {
|
||||
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<string>()
|
||||
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 (
|
||||
<section class="route-empty">
|
||||
<p class="eyebrow">Profile</p>
|
||||
<h1>Profile Settings</h1>
|
||||
<p>Profile identity, account preferences, and workspace defaults will live here.</p>
|
||||
<section class="config-shell profile-shell" classList={{ disconnected: disconnected() }}>
|
||||
<Show when={!disconnected()}>
|
||||
<aside class="config-sidebar" aria-label="Profile sections">
|
||||
<div class="config-sidebar-title">
|
||||
<span>Profile</span>
|
||||
<span class="config-sidebar-scope">
|
||||
<span>{scope()}</span>
|
||||
</span>
|
||||
</div>
|
||||
<nav class="config-options">
|
||||
<A class="config-top-option active" href={overview()} aria-current="page">
|
||||
<span>Overview</span>
|
||||
</A>
|
||||
<a class="config-top-option" href={usageUrl()} target="_blank" rel="noreferrer">
|
||||
<span>Usage</span>
|
||||
</a>
|
||||
<a class="config-top-option" href={creditsUrl()} target="_blank" rel="noreferrer">
|
||||
<span>Buy Credits</span>
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
</Show>
|
||||
<section class="content">
|
||||
<div class="profile-page">
|
||||
<Show when={!disconnected()}>
|
||||
<header class="profile-header">
|
||||
<div>
|
||||
<p class="eyebrow">Kilo Account</p>
|
||||
<h1>Your Profile</h1>
|
||||
<p>Manage your Kilo identity, account context, credits, and billing shortcuts.</p>
|
||||
</div>
|
||||
<div class="profile-actions">
|
||||
<Button variant="secondary" type="button" onClick={refresh} disabled={data.loading || !server.query()}>
|
||||
Refresh
|
||||
</Button>
|
||||
<a
|
||||
class="profile-primary-link"
|
||||
data-component="button"
|
||||
data-size="default"
|
||||
data-variant="primary"
|
||||
href={cloud()}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
</Show>
|
||||
|
||||
<Show when={!server.query() && server.discoverable()}>
|
||||
<LoadingScreen variant="fullscreen" />
|
||||
</Show>
|
||||
|
||||
<Show when={data.loading && !data()}>
|
||||
<LoadingScreen variant="fullscreen" />
|
||||
</Show>
|
||||
|
||||
<Show when={disconnected()}>
|
||||
<Card class="profile-connect-card">
|
||||
<span class="profile-connect-mark" aria-hidden="true">
|
||||
KG
|
||||
</span>
|
||||
<div class="profile-connect-copy">
|
||||
<p class="eyebrow">Kilo Account</p>
|
||||
<h1>Connect your Kilo account</h1>
|
||||
<p>Sign in to view your credits, organizations, and account details in Kilo Console.</p>
|
||||
</div>
|
||||
<A
|
||||
class="profile-primary-link"
|
||||
data-component="button"
|
||||
data-size="default"
|
||||
data-variant="primary"
|
||||
href={login()}
|
||||
>
|
||||
Connect
|
||||
</A>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show when={!server.query() && !server.discoverable()}>
|
||||
<Card class="profile-banner" variant="warning">
|
||||
<strong>Kilo server not found</strong>
|
||||
<span>Start Kilo Console from a running Kilo server or pass a server URL with ?server=.</span>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show when={data.error && !authError(data.error)}>
|
||||
<Card class="profile-banner" variant="error">
|
||||
<strong>Profile request failed</strong>
|
||||
<span>{errMsg(data.error)}</span>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
<Card class="profile-banner" variant="error">
|
||||
<strong>Account update failed</strong>
|
||||
<span>{error()}</span>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show when={profile()}>
|
||||
{(info) => (
|
||||
<div class="profile-grid">
|
||||
<Card class="profile-card profile-account-card">
|
||||
<div class="profile-account-head">
|
||||
<span class="profile-avatar" aria-hidden="true">
|
||||
{initials(info().profile.name, info().profile.email)}
|
||||
</span>
|
||||
<div class="profile-account-text">
|
||||
<strong>{info().profile.name || info().profile.email}</strong>
|
||||
<span>{info().profile.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-meta-row">
|
||||
<span>Active account</span>
|
||||
<strong>{account(info())}</strong>
|
||||
</div>
|
||||
<div class="profile-card-actions">
|
||||
<Button variant="ghost" type="button" onClick={logout} disabled={Boolean(saving())}>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="profile-card profile-balance-card">
|
||||
<span class="profile-card-label">Remaining Credits</span>
|
||||
<strong>{money(info().balance?.balance)}</strong>
|
||||
<p>
|
||||
Credits shown for {account(info())}. Switch accounts below to see organization balances when
|
||||
available.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card class="profile-card profile-wide-card">
|
||||
<header class="profile-card-head">
|
||||
<div>
|
||||
<span class="profile-card-label">Organizations</span>
|
||||
<h2>Your Accounts</h2>
|
||||
</div>
|
||||
<span class="profile-pill">{orgs().length === 1 ? "1 org" : `${orgs().length} orgs`}</span>
|
||||
</header>
|
||||
<div class="profile-org-list">
|
||||
<button
|
||||
type="button"
|
||||
class="profile-org-row"
|
||||
classList={{ active: !info().currentOrgId }}
|
||||
onClick={() => choose(null)}
|
||||
disabled={Boolean(saving())}
|
||||
>
|
||||
<span class="profile-org-icon" aria-hidden="true">
|
||||
KG
|
||||
</span>
|
||||
<span class="profile-org-body">
|
||||
<strong>Personal Account</strong>
|
||||
<span>Your personal Kilo credits and settings</span>
|
||||
</span>
|
||||
<span class="profile-org-state">{!info().currentOrgId ? "Current" : "Use"}</span>
|
||||
</button>
|
||||
<For each={orgs()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="profile-org-row"
|
||||
classList={{ active: active()?.id === item.id }}
|
||||
onClick={() => choose(item.id)}
|
||||
disabled={Boolean(saving())}
|
||||
>
|
||||
<span class="profile-org-icon" aria-hidden="true">
|
||||
{initials(item.name, item.name)}
|
||||
</span>
|
||||
<span class="profile-org-body">
|
||||
<strong>{item.name}</strong>
|
||||
<span>{role(item)}</span>
|
||||
</span>
|
||||
<span class="profile-org-state">{active()?.id === item.id ? "Current" : "Use"}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
@@ -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<string, string | null | undefined>) {
|
||||
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)}`)
|
||||
}
|
||||
@@ -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<ProjectQuery | undefined>(() => {
|
||||
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 }
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user