This commit is contained in:
2026-02-16 19:34:35 +00:00
commit 9ab0940ca4
784 changed files with 41710 additions and 0 deletions
@@ -0,0 +1,251 @@
import { useState } from 'react';
import { Terminal, Copy, Check } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Card } from '@/components/Card';
import { useClient } from 'hooks/useClient';
type Harnesses = {
claudeCode: boolean;
opencode: boolean;
};
type AIHarnessesCardProps = {
onNext: () => void;
onBack: () => void;
saveSettings: (settings: Record<string, unknown>) => Promise<void>;
};
export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCardProps) => {
const client = useClient();
const queryClient = useQueryClient();
const [harnesses, setHarnesses] = useState<Harnesses>({ claudeCode: false, opencode: false });
const [installing, setInstalling] = useState<{ claudeCode: boolean; opencode: boolean }>({
claudeCode: false,
opencode: false,
});
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
type OpencodeAuthInfo = { authenticated: boolean; providers: string[] };
const { data: claudeVersion, isLoading: claudeLoading } = useQuery({
queryKey: ['CLAUDE_CODE_VERSION'],
queryFn: () => client.get<VersionInfo>('/server-settings/claude-code/version'),
enabled: harnesses.claudeCode,
refetchInterval: (query) => {
const data = query.state.data;
return data?.version && !data?.globalPath ? 1000 : false;
},
});
const { data: opencodeVersion, isLoading: opencodeLoading } = useQuery({
queryKey: ['OPENCODE_VERSION'],
queryFn: () => client.get<VersionInfo>('/server-settings/opencode/version'),
enabled: harnesses.opencode,
refetchInterval: (query) => {
const data = query.state.data;
return data?.version && !data?.globalPath ? 1000 : false;
},
});
const { data: claudeAuth } = useQuery({
queryKey: ['CLAUDE_CODE_AUTH'],
queryFn: () => client.get<ClaudeAuthInfo>('/server-settings/claude-code/auth'),
enabled: !!claudeVersion?.version,
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
});
const { data: opencodeAuth } = useQuery({
queryKey: ['OPENCODE_AUTH'],
queryFn: () => client.get<OpencodeAuthInfo>('/server-settings/opencode/auth'),
enabled: !!opencodeVersion?.version,
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
});
const installClaude = async () => {
setInstalling((prev) => ({ ...prev, claudeCode: true }));
try {
const result = await client.post<VersionInfo>('/server-settings/claude-code/install');
queryClient.setQueryData(['CLAUDE_CODE_VERSION'], result);
} finally {
setInstalling((prev) => ({ ...prev, claudeCode: false }));
}
};
const installOpencode = async () => {
setInstalling((prev) => ({ ...prev, opencode: true }));
try {
const result = await client.post<VersionInfo>('/server-settings/opencode/install');
queryClient.setQueryData(['OPENCODE_VERSION'], result);
} finally {
setInstalling((prev) => ({ ...prev, opencode: false }));
}
};
const [copied, setCopied] = useState<string | null>(null);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setCopied(text);
setTimeout(() => setCopied(null), 1500);
};
const CopyCommand = ({ command }: { command: string }) => (
<div className="mt-2 text-xs text-amber-600">
Not globally accessible. Run:
<div className="flex items-center gap-1 mt-1">
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-duck-dark/70">{command}</code>
<button
type="button"
onClick={() => copyToClipboard(command)}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
{copied === command ? (
<Check className="h-3.5 w-3.5 text-green-600" />
) : (
<Copy className="h-3.5 w-3.5 text-duck-dark/50" />
)}
</button>
</div>
</div>
);
const claudeReady =
!harnesses.claudeCode || (!!claudeVersion?.version && !!claudeVersion?.globalPath && !!claudeAuth?.authenticated);
const opencodeReady =
!harnesses.opencode ||
(!!opencodeVersion?.version && !!opencodeVersion?.globalPath && !!opencodeAuth?.authenticated);
const canProceed = (harnesses.claudeCode || harnesses.opencode) && claudeReady && opencodeReady;
const handleNext = async () => {
await saveSettings({ aiHarnesses: harnesses, onboardingComplete: true });
onNext();
};
return (
<Card className="p-6">
<div className="flex items-center gap-3 mb-2">
<Terminal className="h-5 w-5 text-duck-forest" />
<h2 className="text-xl font-bold text-duck-dark">AI Harnesses</h2>
</div>
<p className="text-duck-dark/70 text-sm mb-6">Which AI coding tools do you use?</p>
<div className="flex flex-col gap-4">
<div>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={harnesses.opencode}
onCheckedChange={(checked) => setHarnesses((prev) => ({ ...prev, opencode: !!checked }))}
/>
<span className="text-sm font-medium text-duck-dark">Opencode</span>
</label>
{harnesses.opencode && (
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
{opencodeLoading ? (
'Checking version...'
) : opencodeVersion?.version ? (
<>
<div>{opencodeVersion.version}</div>
<div>{opencodeVersion.path}</div>
{opencodeAuth && (
<div className={`mt-1 ${opencodeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
{opencodeAuth.authenticated ? (
`Logged in (${opencodeAuth.providers.join(', ')})`
) : (
<div className="flex items-center gap-2">
<span>Not logged in</span>
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={() => client.post('/server-settings/opencode/auth/login')}
>
Login
</Button>
</div>
)}
</div>
)}
{!opencodeVersion.globalPath && opencodeVersion.path && (
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} />
)}
</>
) : (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={installOpencode}
disabled={installing.opencode}
>
{installing.opencode ? 'Installing...' : 'Install'}
</Button>
)}
</div>
)}
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={harnesses.claudeCode}
onCheckedChange={(checked) => setHarnesses((prev) => ({ ...prev, claudeCode: !!checked }))}
/>
<span className="text-sm font-medium text-duck-dark">Claude Code</span>
</label>
{harnesses.claudeCode && (
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
{claudeLoading ? (
'Checking version...'
) : claudeVersion?.version ? (
<>
<div>{claudeVersion.version}</div>
<div>{claudeVersion.path}</div>
{claudeAuth && (
<div className={`mt-1 ${claudeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
{claudeAuth.authenticated ? (
`Logged in (${claudeAuth.subscriptionType ?? 'unknown plan'})`
) : (
<div className="flex items-center gap-2">
<span>Not logged in</span>
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={() => client.post('/server-settings/claude-code/auth/login')}
>
Login
</Button>
</div>
)}
</div>
)}
{!claudeVersion.globalPath && claudeVersion.path && (
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} />
)}
</>
) : (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={installOpencode}
disabled={installing.opencode}
>
{installing.opencode ? 'Installing...' : 'Install'}
</Button>
)}
</div>
)}
</div>
</div>
<div className="flex justify-between mt-6">
<Button variant="outline" onClick={onBack}>
Back
</Button>
<Button disabled={!canProceed} onClick={handleNext}>
Complete Setup
</Button>
</div>
</Card>
);
};
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { Building2, UserRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
type AccountMode = 'organization' | 'single' | null;
type ServerTypeCardProps = {
onNext: () => void;
saveSettings: (settings: Record<string, unknown>) => Promise<void>;
};
export const ServerTypeCard = ({ onNext, saveSettings }: ServerTypeCardProps) => {
const [accountMode, setAccountMode] = useState<AccountMode>(null);
const handleNext = async () => {
await saveSettings({ accountMode });
onNext();
};
return (
<Card className="p-6">
<h2 className="text-xl font-bold text-duck-dark mb-2">Account Type</h2>
<p className="text-duck-dark/70 text-sm mb-6">How will you be using Officer?</p>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => setAccountMode('single')}
className={`flex flex-col items-center gap-3 p-6 rounded-lg border-2 cursor-pointer transition-colors ${
accountMode === 'single'
? 'border-duck-teal bg-duck-teal/10'
: 'border-duck-dark/20 hover:border-duck-dark/40'
}`}
>
<UserRound className={`h-8 w-8 ${accountMode === 'single' ? 'text-duck-teal' : 'text-duck-dark/50'}`} />
<span className={`text-sm font-medium ${accountMode === 'single' ? 'text-duck-teal' : 'text-duck-dark'}`}>
Single User
</span>
<span className="text-xs text-duck-dark/50 text-center">Just me, personal use</span>
</button>
<button
type="button"
onClick={() => setAccountMode('organization')}
className={`flex flex-col items-center gap-3 p-6 rounded-lg border-2 cursor-pointer transition-colors ${
accountMode === 'organization'
? 'border-duck-teal bg-duck-teal/10'
: 'border-duck-dark/20 hover:border-duck-dark/40'
}`}
>
<Building2 className={`h-8 w-8 ${accountMode === 'organization' ? 'text-duck-teal' : 'text-duck-dark/50'}`} />
<span
className={`text-sm font-medium ${accountMode === 'organization' ? 'text-duck-teal' : 'text-duck-dark'}`}
>
Organization
</span>
<span className="text-xs text-duck-dark/50 text-center">Multiple users and teams</span>
</button>
</div>
<div className="flex justify-end mt-6">
<Button disabled={!accountMode} onClick={handleNext}>
Next
</Button>
</div>
</Card>
);
};
@@ -0,0 +1,60 @@
import { useState, useEffect } from 'react';
import { useServerSettings } from '@/state/useServerSettings';
import { DashboardLayout } from '../Layout';
import { ServerTypeCard } from './ServerTypeCard';
import { AIHarnessesCard } from './AIHarnessesCard';
const STEPS = ['server-type', 'ai-harnesses'] as const;
type Step = (typeof STEPS)[number];
const getStepFromHash = (): Step => {
const hash = window.location.hash.slice(1);
if (STEPS.includes(hash as Step)) return hash as Step;
return STEPS[0]!;
};
const setHash = (step: Step) => {
window.location.hash = step;
};
export const OnboardingAdmin = () => {
const { saveSettings } = useServerSettings();
const [step, setStep] = useState<Step>(getStepFromHash);
useEffect(() => {
const onHashChange = () => setStep(getStepFromHash());
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
useEffect(() => {
setHash(step);
}, [step]);
const currentIndex = STEPS.indexOf(step);
const nextStep = () => {
if (currentIndex < STEPS.length - 1) {
setStep(STEPS[currentIndex + 1]!);
}
};
const prevStep = () => {
if (currentIndex > 0) {
setStep(STEPS[currentIndex - 1]!);
}
};
return (
<DashboardLayout>
<div className="flex items-center justify-center h-full px-4">
<div className="w-full max-w-lg">
{step === 'server-type' && <ServerTypeCard onNext={nextStep} saveSettings={saveSettings} />}
{step === 'ai-harnesses' && (
<AIHarnessesCard onNext={nextStep} onBack={prevStep} saveSettings={saveSettings} />
)}
</div>
</div>
</DashboardLayout>
);
};