Skip to content
Open
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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ AUTH_SECRET=CHANGE_ME_GENERATE_WITH_openssl_rand_base64_32
# Passkey/WebAuthn Configuration
PASSKEY_RP_ID=localhost
PASSKEY_RP_NAME=OpenCode Manager
PASSKEY_ORIGIN=http://localhost:5003
# Exact origin for WebAuthn verification. Leave empty/unset to use the browser
# Origin header (recommended when the same RP ID is served on multiple ports,
# e.g. Tailscale prod :5003 + local HMR :5174).
PASSKEY_ORIGIN=

# ============================================
# Push Notifications (VAPID)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ node_modules/
*.log
.env
.env.local
.env.*.local
workspace-dev/
dist/
build/
coverage/
Expand Down
7 changes: 5 additions & 2 deletions backend/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ export function createAuth(db: Database) {
passkey({
rpID: ENV.AUTH.PASSKEY_RP_ID,
rpName: ENV.AUTH.PASSKEY_RP_NAME,
origin: ENV.AUTH.PASSKEY_ORIGIN,
// Omit origin so verification uses the request Origin header.
// That lets the same RP ID work across Tailscale ports (prod :5003, HMR :5174).
...(ENV.AUTH.PASSKEY_ORIGIN ? { origin: ENV.AUTH.PASSKEY_ORIGIN } : {}),
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred',
Expand All @@ -74,7 +76,8 @@ export function createAuth(db: Database) {
},
},
advanced: {
cookiePrefix: 'opencode',
// Isolate dev cookies from production when both share a Tailscale hostname
cookiePrefix: ENV.SERVER.NODE_ENV === 'development' ? 'opencode-dev' : 'opencode',
useSecureCookies: ENV.AUTH.SECURE_COOKIES,
},
})
Expand Down
2 changes: 1 addition & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ void scheduleRunnerInstance.start()

const settingsService = new SettingsService(db)

app.route('/api/auth', createAuthRoutes(auth))
app.route('/api/auth', createAuthRoutes(auth, db))
app.route('/api/auth-info', createAuthInfoRoutes(auth, db))
app.route('/api/health', createHealthRoutes(db, openCodeSupervisor))

Expand Down
63 changes: 60 additions & 3 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,76 @@ import { ENV } from '@opencode-manager/shared/config/env'
import { logger } from '../utils/logger'
import { hashPassword } from 'better-auth/crypto'

export function createAuthRoutes(auth: AuthInstance): Hono {
type PasskeyRow = {
credentialID: string
transports: string | null
}

function withAllowCredentials(body: string, passkeys: PasskeyRow[]): string {
try {
const options = JSON.parse(body) as {
allowCredentials?: Array<{ id: string; type?: string; transports?: string[] }>
[key: string]: unknown
}
if (options.allowCredentials?.length) {
return body
}
if (!passkeys.length) {
return body
}
options.allowCredentials = passkeys.map((pk) => ({
id: pk.credentialID,
type: 'public-key',
transports: pk.transports
? pk.transports.split(',').map((t) => t.trim()).filter(Boolean)
: undefined,
}))
return JSON.stringify(options)
} catch {
return body
}
}

export function createAuthRoutes(auth: AuthInstance, db?: Database): Hono {
const app = new Hono()

app.all('/*', async (c) => {
const response = await auth.handler(c.req.raw)

const setCookie = response.headers.get('set-cookie')
if (c.req.path.includes('sign-in')) {
logger.info(`Sign-in response - Status: ${response.status}, Set-Cookie: ${setCookie ? 'present' : 'missing'}`)
if (setCookie) {
logger.debug(`Set-Cookie header: ${setCookie.substring(0, 100)}...`)
}
}


// better-auth only includes allowCredentials when a session exists.
// Discoverable (empty) options often make Windows Hello cancel immediately.
// Inject stored credential IDs so the authenticator can match the passkey.
if (
db &&
response.ok &&
c.req.method === 'GET' &&
c.req.path.includes('generate-authenticate-options')
) {
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
const passkeys = db
.prepare('SELECT credentialID, transports FROM passkey')
.all() as PasskeyRow[]
const body = await response.text()
const nextBody = withAllowCredentials(body, passkeys)
const headers = new Headers(response.headers)
headers.delete('content-length')
return new Response(nextBody, {
status: response.status,
statusText: response.statusText,
headers,
})
}
}

return response
})

Expand Down Expand Up @@ -91,6 +147,7 @@ export function createAuthInfoRoutes(auth: AuthInstance, db: Database) {
registrationEnabled: !adminConfigured,
isFirstUser: hasUsers.count === 0,
adminConfigured,
passkeyRpId: ENV.AUTH.PASSKEY_RP_ID,
})
})

Expand Down
5 changes: 3 additions & 2 deletions frontend/components.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"config": "",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
Expand Down
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@
"react-dom": "^19.1.1",
"react-hook-form": "^7.65.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.7.2",
"react-router-dom": "^7.13.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface GitIdentity {

export interface UserPreferences {
theme: 'dark' | 'light' | 'system'
themePreset?: string
mode: 'plan' | 'build'
defaultModel?: string
defaultAgent?: string
Expand Down
42 changes: 21 additions & 21 deletions frontend/src/components/file-browser/FileDiffView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,25 +93,25 @@ const statusConfig: Record<
modified: {
icon: FileEdit,
color: GIT_STATUS_COLORS.modified,
bgColor: "bg-amber-500/10",
bgColor: "bg-warning/10",
label: GIT_STATUS_LABELS.modified,
},
added: {
icon: FilePlus,
color: GIT_STATUS_COLORS.added,
bgColor: "bg-emerald-500/10",
bgColor: "bg-success/10",
label: GIT_STATUS_LABELS.added,
},
deleted: {
icon: FileX,
color: GIT_STATUS_COLORS.deleted,
bgColor: "bg-rose-500/10",
bgColor: "bg-destructive/10",
label: GIT_STATUS_LABELS.deleted,
},
renamed: {
icon: FileText,
color: GIT_STATUS_COLORS.renamed,
bgColor: "bg-blue-500/10",
bgColor: "bg-info/10",
label: GIT_STATUS_LABELS.renamed,
},
untracked: {
Expand All @@ -123,7 +123,7 @@ const statusConfig: Record<
copied: {
icon: FileText,
color: GIT_STATUS_COLORS.copied,
bgColor: "bg-emerald-500/10",
bgColor: "bg-success/10",
label: GIT_STATUS_LABELS.copied,
},
};
Expand Down Expand Up @@ -155,31 +155,31 @@ function DiffLineComponent({

const bgClass =
line.type === "add"
? "bg-emerald-500/10"
? "bg-success/10"
: line.type === "remove"
? "bg-rose-500/10"
? "bg-destructive/10"
: "";

const textClass =
line.type === "add"
? "text-emerald-700 dark:text-emerald-300"
? "text-success"
: line.type === "remove"
? "text-rose-700 dark:text-rose-300"
? "text-destructive"
: "text-foreground";

const lineNumber = line.newLineNumber ?? line.oldLineNumber;
const isClickable = onLineClick && lineNumber !== undefined;

return (
<div
className={cn(
"flex font-mono text-sm border-l-2 transition-colors min-w-0",
bgClass,
line.type === "add" && "border-l-emerald-500",
line.type === "remove" && "border-l-rose-500",
line.type === "context" && "border-l-transparent",
isClickable && "cursor-pointer hover:bg-accent/30",
)}
className={cn(
"flex font-mono text-sm border-l-2 transition-colors min-w-0",
bgClass,
line.type === "add" && "border-l-success",
line.type === "remove" && "border-l-destructive",
line.type === "context" && "border-l-transparent",
isClickable && "cursor-pointer hover:bg-accent/30",
)}
onClick={() =>
isClickable && lineNumber !== undefined && onLineClick(lineNumber)
}
Expand All @@ -196,10 +196,10 @@ function DiffLineComponent({
)}
<div className="w-6 flex-shrink-0 flex items-center justify-center bg-muted/20">
{line.type === "add" && (
<Plus className="w-3 h-3 text-emerald-600 dark:text-emerald-400" />
<Plus className="w-3 h-3 text-success" />
)}
{line.type === "remove" && (
<Minus className="w-3 h-3 text-rose-600 dark:text-rose-400" />
<Minus className="w-3 h-3 text-destructive" />
)}
</div>
<pre
Expand Down Expand Up @@ -325,8 +325,8 @@ export function FileDiffView({
</span>
{!diffData.isBinary && (
<>
<span className="text-green-500">+{diffData.additions}</span>
<span className="text-red-500">-{diffData.deletions}</span>
<span className="text-success">+{diffData.additions}</span>
<span className="text-destructive">-{diffData.deletions}</span>
</>
)}
{diffData.diff && (
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/file-browser/FilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
return (
<div
key={index}
className={`flex transition-colors duration-300 ${isHighlighted ? 'bg-yellow-500/30' : ''}`}
className={`flex transition-colors duration-300 ${isHighlighted ? 'bg-warning/20' : ''}`}
style={{ minHeight: '20px', lineHeight: '20px' }}
>
<span className="w-12 flex-shrink-0 text-right pr-3 text-muted-foreground select-none border-r border-border/50 text-xs">
Expand Down Expand Up @@ -372,10 +372,10 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
<span className="truncate flex-shrink-0">{formatFileSize(file.size)}</span>
<span className="hidden sm:inline truncate flex-shrink-0">{formatDate(file.lastModified)}</span>
{shouldVirtualize && (
<span className="text-xs text-blue-500 flex-shrink-0">Virtualized</span>
<span className="text-xs text-info flex-shrink-0">Virtualized</span>
)}
{hasVirtualizedChanges && (
<span className="text-xs text-yellow-500 flex-shrink-0">Unsaved changes</span>
<span className="text-xs text-warning flex-shrink-0">Unsaved changes</span>
)}
</div>
</div>
Expand Down Expand Up @@ -412,7 +412,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
)}

{showSaveButton && (
<Button variant="outline" size="sm" onClick={(e) => { e.stopPropagation(); e.preventDefault(); if (shouldVirtualize) { handleVirtualizedSaveClick(); } else { handleSave(); } }} disabled={isSaving || (shouldVirtualize && !hasVirtualizedChanges)} className="border-green-600 bg-green-600/10 text-green-600 hover:bg-green-600/20 h-7 w-7 p-0">
<Button variant="outline" size="sm" onClick={(e) => { e.stopPropagation(); e.preventDefault(); if (shouldVirtualize) { handleVirtualizedSaveClick(); } else { handleSave(); } }} disabled={isSaving || (shouldVirtualize && !hasVirtualizedChanges)} className="h-7 w-7 border-success/30 bg-success/10 p-0 text-success hover:bg-success/20">
<Save className="w-3 h-3" />
</Button>
)}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/file-browser/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ function TreeNode({ file, level, onFileSelect, onDirectoryClick, selectedFile, o
<Edit3 className="w-4 h-4 mr-2" />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDelete} className="text-red-600">
<DropdownMenuItem onClick={handleDelete} className="text-destructive">
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
Expand Down
14 changes: 7 additions & 7 deletions frontend/src/components/message/ContentDiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ export function ContentDiffViewer({ before, after }: ContentDiffViewerProps) {
key={index}
className={cn(
'flex font-mono text-xs',
isAdd && 'bg-green-500/10',
isRemove && 'bg-red-500/10',
isAdd && 'bg-success/10',
isRemove && 'bg-destructive/10',
)}
>
{!isMobile && (
Expand All @@ -132,14 +132,14 @@ export function ContentDiffViewer({ before, after }: ContentDiffViewerProps) {
</div>
)}
<div className="w-4 flex-shrink-0 flex items-center justify-center">
{isAdd && <Plus className="w-2.5 h-2.5 text-green-500" />}
{isRemove && <Minus className="w-2.5 h-2.5 text-red-500" />}
{isAdd && <Plus className="w-2.5 h-2.5 text-success" />}
{isRemove && <Minus className="w-2.5 h-2.5 text-destructive" />}
</div>
<pre
className={cn(
'flex-1 px-1 py-0.5 whitespace-pre-wrap break-all',
isAdd && 'text-green-600 dark:text-green-400',
isRemove && 'text-red-600 dark:text-red-400',
isAdd && 'text-success',
isRemove && 'text-destructive',
)}
>
{truncateLine(line.content)}
Expand Down Expand Up @@ -170,4 +170,4 @@ export function ContentDiffViewer({ before, after }: ContentDiffViewerProps) {
)}
</div>
)
}
}
6 changes: 3 additions & 3 deletions frontend/src/components/message/DiffStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ export function DiffStats({ additions, deletions, variant = 'default' }: DiffSta
return (
<span className="flex items-center gap-1 text-xs font-mono">
{additions > 0 && (
<span className="text-green-600 dark:text-green-400">
<span className="text-success">
{compact ? `+${additions}` : `+${additions}`}
</span>
)}
{additions > 0 && deletions > 0 && (
<span className="text-muted-foreground">{compact ? '/' : ' '}</span>
)}
{deletions > 0 && (
<span className="text-red-600 dark:text-red-400">
<span className="text-destructive">
{compact ? `-${deletions}` : `-${deletions}`}
</span>
)}
</span>
)
}
}
2 changes: 1 addition & 1 deletion frontend/src/components/message/EditableUserMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export const ClickableUserMessage = memo(function ClickableUserMessage({
return (
<button
onClick={onClick}
className="text-left text-sm whitespace-pre-wrap break-words w-full group/edit hover:bg-blue-600/10 rounded p-1 -m-1 transition-colors flex items-start gap-2"
className="group/edit -m-1 flex w-full items-start gap-2 rounded p-1 text-left text-sm whitespace-pre-wrap break-words transition-colors hover:bg-primary/10"
title="Click to edit and resend"
>
<span className="flex-1">{content}</span>
Expand Down
Loading