Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ file is the human-readable summary of what changed and why.

## Unreleased

### Desktop

- **Account entry in the sidebar** — the sidebar's bottom cluster now shows,
right under Settings, a **Log in** button while signed out (opening the same
configure dialog as the Log in/Configure menu item) and, once signed in, an
avatar with your initials. The avatar's menu holds **Help** — moved there
from the sidebar, which no longer shows a Help row — and a new **Log out**.
Logging out mirrors `codevhub logout`: it ends the CoDev SSO session (and
revokes its tokens) but keeps the gateway API key, so chat keeps working;
the next sign-in asks for credentials again.

### Sessions

- **Git branch in the session composer** — open sessions now show the current
Expand Down
4 changes: 2 additions & 2 deletions DIVERGENCES.md

Large diffs are not rendered by default.

132 changes: 132 additions & 0 deletions packages/app/src/components/account-nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { DOCS_URL } from "@codev/brand"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { createSignal, onCleanup, onMount, Show } from "solid-js"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import type { GatewayAccount } from "@/gateway/types"
import { HOME_PROJECT_NAV_LABEL, HOME_PROJECT_NAV_ROW } from "@/pages/home"
import { showToast } from "@/utils/toast"

// Fork-only: the sidebar's account entry, rendered directly under Settings
// (see app-sidebar.tsx). Logged out it is a "Log in" row that opens the same
// configure dialog as the Log in/Configure command; logged in it is an
// initials avatar whose menu holds Help (which lives only here) and Log out.
// Hidden entirely when the platform has no SSO bridge (the web build).
export function AccountNav() {
const language = useLanguage()
const platform = usePlatform()
const command = useCommand()

const sso = platform.gatewaySso
if (!sso) return null

const [account, setAccount] = createSignal<GatewayAccount | null>(null)
const refresh = () => void sso.getAccount().then(setAccount)
onMount(() => {
refresh()
// Login and logout both emit a state event — in every window — so the
// avatar follows the auth file without polling it.
onCleanup(sso.subscribe(refresh))
})

const logout = async () => {
try {
await sso.logout()
} catch (error) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
}
refresh()
}

return (
<Show
when={account()}
fallback={
<button
type="button"
data-component="account-nav"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={() => command.trigger("gateway.apiKey.new")}
>
<PersonIcon />
<span class={HOME_PROJECT_NAV_LABEL}>{language.t("sidebar.login")}</span>
</button>
}
>
{(current) => (
<MenuV2 gutter={6} modal={false} placement="top-start">
{/* The rows above are full-width buttons; this one is deliberately
just the circle (per design), left-aligned to the rows' px-1.5
text inset. The email rides on title for hover disambiguation. */}
<MenuV2.Trigger
data-component="account-nav"
class="ml-1.5 flex size-8 shrink-0 cursor-default items-center justify-center rounded-full border-0 bg-v2-background-bg-layer-03 text-[12px] [font-weight:530] text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] transition-[box-shadow] duration-[120ms] hover:[box-shadow:inset_0_0_0_1px_var(--v2-border-border-muted)] focus-visible:outline-none focus-visible:[box-shadow:inset_0_0_0_1px_var(--v2-border-border-muted)]"
aria-label={language.t("sidebar.account")}
title={current().email}
>
{initials(current())}
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Group>
{/* Who this menu acts for — a label, not an item. */}
<MenuV2.GroupLabel>{current().email}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => platform.openExternal(DOCS_URL)}>{language.t("sidebar.help")}</MenuV2.Item>
<MenuV2.Separator />
{/* Danger colour comes from the item's own `--menu-v2-fg` rather
than a text utility: menu-v2.css colours the label span
directly, so a class on the item would not reach it. */}
<MenuV2.Item style={{ "--menu-v2-fg": "var(--v2-state-fg-danger)" }} onSelect={() => void logout()}>
{language.t("sidebar.logout")}
</MenuV2.Item>
</MenuV2.Group>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)}
</Show>
)
}

// Two initials from the display name: first letters of the first and *last*
// words — not the first two — so a family-name-first name like "Nguyễn Hoàng
// Minh" yields NM (family + given name) rather than family + middle. A single
// word gives its first two characters; the email's local part is the fallback.
// Array.from splits by code point so non-BMP letters survive.
function initials(account: GatewayAccount) {
const source = account.displayName.trim() || account.email.split("@")[0]
const words = source.split(/\s+/).filter(Boolean)
if (words.length >= 2) return `${Array.from(words[0])[0]}${Array.from(words[words.length - 1])[0]}`.toUpperCase()
return Array.from(words[0] ?? "?")
.slice(0, 2)
.join("")
.toUpperCase()
}

// The v2 icon set has no person glyph, and its Icon component falls back to
// "plus" for unknown names — an inline SVG keeps upstream icon.tsx untouched.
// Sized and slotted like Icon size="small" so the row styles reach it.
function PersonIcon() {
return (
<svg
data-slot="icon-svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0"
>
<circle cx="8" cy="5.25" r="2.75" />
<path d="M2.75 13.5c0-2.35 2.35-4 5.25-4s5.25 1.65 5.25 4" />
</svg>
)
}
32 changes: 18 additions & 14 deletions packages/app/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { createMemo, createResource, createSignal, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { A } from "@solidjs/router"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DOCS_URL } from "@codev/brand"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
Expand All @@ -25,17 +24,17 @@ import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { createHomeModel, isBackgroundOpen, type HomeSessionRecord } from "@/pages/home-model"
import { AccountNav } from "@/components/account-nav"
import { SkillHubNav } from "@/components/skill-hub-nav"
// The project rows, server rows and utility nav still live in home.tsx, where
// upstream keeps them — this file composes them into a persistent column and
// adds the Recents section. Only the `export` keywords there are ours.
// The project rows and server rows still live in home.tsx, where upstream
// keeps them — this file composes them into a persistent column and adds the
// Recents section. Only the `export` keywords there are ours.
import {
HOME_PROJECT_NAV_LABEL,
HOME_PROJECT_NAV_ROW,
HomeProjectEmpty,
HomeProjectList,
HomeServerRow,
HomeUtilityNav,
} from "@/pages/home"
import { SandboxMountPrompt } from "@/sandbox/mount-prompt"
import { useSandboxMountDialog } from "@/sandbox/mount-dialog"
Expand Down Expand Up @@ -259,19 +258,24 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
{/* An expanded section is the flexible child that holds this down. When
every section is collapsed nothing grows, so the auto margin takes
over and the slack opens below the last section instead of pushing
it — and Skill Hub/Settings/Help — to the bottom of the column.
The wrapper carries the auto margin so the fork's Skill Hub row can
sit directly above Settings without editing HomeUtilityNav. */}
it — and Skill Hub/Settings/Account — to the bottom of the column.
The Settings row is a fork-only copy of HomeUtilityNav's (which the
legacy mounts keep using): Help moved into the account menu, and
gating it out of HomeUtilityNav would be a bigger upstream edit
than not calling it. */}
<div class="mt-auto flex min-w-0 shrink-0 flex-col gap-1">
<div class="min-w-0 flex-col gap-1 pr-3 flex">
<SkillHubNav />
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={openSettings}
>
<IconV2 name="settings-gear" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{language.t("sidebar.settings")}</span>
</button>
<AccountNav />
</div>
<HomeUtilityNav
class="flex shrink-0"
openSettings={openSettings}
openHelp={() => platform.openExternal(DOCS_URL)}
language={language}
/>
</div>

{/* The DEV / BETA badge, moved out of the titlebar so Home can lead it.
Expand Down
18 changes: 16 additions & 2 deletions packages/app/src/components/gateway/gateway-configure-gate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { shouldOpenConfigureGate } from "@/gateway/configure-gate"
import { showToast } from "@/utils/toast"
import { dismissToast, showToast } from "@/utils/toast"
import { DialogConfigure } from "./dialog-configure"

// ServerScopedProviders can be mounted by the legacy and new shells at the
Expand Down Expand Up @@ -101,9 +101,23 @@ export function GatewayConfigureGate() {
// rather than timed: a toast that auto-dismisses while the user is reading
// code is the same silence the nudge exists to break.
onMount(() => {
// Signing back in is the toast's entire ask, from whichever entry point —
// its own action, the sidebar's Log in, the menu item — so a completed
// login (phase "ready", set only by startLogin's success) retires it
// instead of leaving it to the close button. Subscribed synchronously so
// the cleanup stays under this component's owner.
let nudgeToast: number | undefined
onCleanup(
platform.gatewaySso!.subscribe((state) => {
if (state.phase !== "ready") return
if (nudgeToast === undefined) return
dismissToast(nudgeToast)
nudgeToast = undefined
}),
)
void platform.gatewaySso?.consumeUploadSignInNudge().then((stalled) => {
if (!stalled) return
showToast({
nudgeToast = showToast({
persistent: true,
icon: "cloud-upload",
title: language.t("toast.sessionUpload.paused.title"),
Expand Down
15 changes: 15 additions & 0 deletions packages/app/src/gateway/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ export type GatewayKeyCheck =
// No key, or one that cannot be replaced without a browser sign-in.
| { status: "needs-login" }

// The signed-in identity from the shared auth file — present whenever an SSO
// session (live or refresh-revivable) is on disk, independent of the login
// dialog's transient state.
export type GatewayAccount = {
email: string
displayName: string
}

export type GatewaySsoPlatform = {
getState(): Promise<GatewaySsoState>
subscribe(cb: (state: GatewaySsoState) => void): () => void
Expand All @@ -80,4 +88,11 @@ export type GatewaySsoPlatform = {
// because signing back in is the whole remedy. Consumed: the main process
// hands the stall to the first asker, so a second window stays quiet.
consumeUploadSignInNudge(): Promise<boolean>
// Reads the signed-in identity from the shared auth file; null when logged
// out. Callers refetch on subscribe events — login and logout both emit.
getAccount(): Promise<GatewayAccount | null>
// Hub-parity sign-out: ends the SSO session (tokens revoked best-effort,
// force-login sentinel left for the next login) but keeps the gateway API
// key, so the configured provider keeps working.
logout(): Promise<void>
}
3 changes: 3 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,9 @@ export const dict = {
"sidebar.nav.projectsAndSessions": "Projects and sessions",
"sidebar.settings": "Settings",
"sidebar.skillhub": "Skill Hub",
"sidebar.login": "Log in",
"sidebar.logout": "Log out",
"sidebar.account": "Account",
"skillhub.title": "Skill Hub",
"skillhub.search.placeholder": "Search skills…",
"skillhub.sort.featured": "Featured",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,9 @@ export const dict = {
"sidebar.nav.projectsAndSessions": "Proyectos y sesiones",
"sidebar.settings": "Ajustes",
"sidebar.skillhub": "Skill Hub",
"sidebar.login": "Iniciar sesión",
"sidebar.logout": "Cerrar sesión",
"sidebar.account": "Cuenta",
"skillhub.title": "Skill Hub",
"skillhub.search.placeholder": "Buscar skills…",
"skillhub.sort.featured": "Destacados",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,9 @@ export const dict = {
"sidebar.nav.projectsAndSessions": "Dự án và session",
"sidebar.settings": "Cài đặt",
"sidebar.skillhub": "Skill Hub",
"sidebar.login": "Đăng nhập",
"sidebar.logout": "Đăng xuất",
"sidebar.account": "Tài khoản",
"skillhub.title": "Skill Hub",
"skillhub.search.placeholder": "Tìm skill…",
"skillhub.sort.featured": "Nổi bật",
Expand Down
70 changes: 70 additions & 0 deletions packages/codev-gateway/src/auth-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ export function forceLoginPath() {
return join(authDir(), "force-login")
}

// Hub-parity sentinel writer (hub src/lib/auth.ts markForceLogin): revoking
// tokens does not end the IdP's browser-session cookie, so logout leaves this
// marker and the next login() bounces through the IdP's /logout first.
export function markForceLogin(): void {
const path = forceLoginPath()
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
writeFileSync(path, "", { mode: 0o600 })
}

export function readAuthFile(): AuthFileContents | null {
try {
return JSON.parse(readFileSync(authFilePath(), "utf-8")) as AuthFileContents
Expand Down Expand Up @@ -196,6 +205,67 @@ export function loadSkillhubCookie(): string | null {
return readAuthFile()?.skillhub_cookie ?? null
}

// Port of hub clearSkillhubCookie (src/lib/auth.ts): drops only the cookie so
// a sign-out ends the admin session without touching the SSO or api-key
// blocks. Deletes the file when the cookie was its only content.
export function clearSkillhubCookie(): boolean {
const raw = readAuthFile()
if (!raw?.skillhub_cookie) return false
const rest: AuthFileContents = { ...raw, skillhub_cookie: undefined }
writeOrDelete(rest)
return true
}

// The signed-in identity for display. A stale access token with a
// refresh_token on disk still counts (silentSso revives it), so this must not
// gate on loadAuth()'s expiry check alone — but a dead-dead session (expired
// access token and nothing to refresh with) reads as logged out. Whether a
// present refresh_token is still honored server-side is unknowable from disk;
// that stays optimistic.
export function loadAccount(): { email: string; displayName: string } | null {
const raw = readAuthFile()
if (!raw?.user) return null
const revivable = raw.refresh_token || (raw.access_token && raw.expires_at && Date.now() <= raw.expires_at)
if (!revivable) return null
return { email: raw.user.email, displayName: raw.user.displayName }
}

// The file half of logout (hub src/lib/auth.ts logout): an allowlist rewrite
// that drops the SSO block and keeps everything else — the gateway api-key
// block, the runtime config, the hub's admin cookie, cached model limits.
// Deletes the file outright when nothing survives. Returns the dropped tokens
// for best-effort revocation, or null when there was no session to clear.
export function clearSsoSession(): { accessToken?: string; refreshToken?: string } | null {
const raw = readAuthFile()
if (!raw) return null
if (!raw.access_token && !raw.refresh_token) return null
const preserved: AuthFileContents = {
api_key: raw.api_key,
base_url: raw.base_url,
model: raw.model,
provider_id: raw.provider_id,
provider_name: raw.provider_name,
supabase_url: raw.supabase_url,
supabase_anon_key: raw.supabase_anon_key,
gateway_url: raw.gateway_url,
skillhub_cookie: raw.skillhub_cookie,
model_limits: raw.model_limits,
}
writeOrDelete(preserved)
return { accessToken: raw.access_token, refreshToken: raw.refresh_token }
}

// JSON.stringify drops undefined values, so a rewrite whose every field is
// undefined would leave a misleading "{}" behind — delete the file instead
// (hub logout does the same).
function writeOrDelete(contents: AuthFileContents): void {
if (Object.values(contents).some((value) => value !== undefined)) {
writeAuthFile(contents)
return
}
unlinkSync(authFilePath())
}

export function saveCodevConfig(config: CodevConfig): void {
writeAuthFile({
...(readAuthFile() ?? {}),
Expand Down
Loading
Loading