mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
* feat(setup): first-run wizard — admin, GitHub App, tunnel, done - GET /api/v1/setup/status (public): has_users, needs_setup, has_github_app, has_tunnel. app_settings.setup_completed flag; installs with existing projects auto-complete so upgrades never see the wizard. - POST /api/v1/setup/complete records the flag. - /setup page: progress rail + four steps — create admin (links into the bootstrap sign-up with redirect back), GitHub App manifest flow, optional Cloudflare tunnel token, finish. Every optional step is skippable; nothing blocks. - PlatformShell redirects signed-in users to /setup while needs_setup. - The GitHub App callback honors a sessionStorage return marker set by the wizard so provisioning resumes at /setup instead of /settings. - OpenAPI documents both endpoints. Verified live: status reports auto-completed on this install, complete persists the flag, response shape matches the client. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(setup): wizard step-state coverage Five cases across the state machine: fresh-install account prompt, sign-in resume, git-provider step, tunnel step, and finish. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
|
import { Check, ChevronRight, Github, Loader2, Rocket, ShieldCheck } from 'lucide-react';
|
|
import { useAuthSession } from '@/lib/use-auth-session';
|
|
import {
|
|
completeSetup,
|
|
createGitHubAppManifest,
|
|
getSetupStatus,
|
|
updatePlatformSettings,
|
|
} from '@/lib/api-client';
|
|
|
|
// Marker the GitHub App callback reads so the wizard flow resumes here
|
|
// instead of the settings page.
|
|
const SETUP_RETURN_KEY = 'containr.setup_return';
|
|
|
|
type Step = 'account' | 'github' | 'tunnel' | 'done';
|
|
|
|
export function SetupWizardPage() {
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const sessionQuery = useAuthSession();
|
|
const statusQuery = useQuery({ queryKey: ['setup-status'], queryFn: getSetupStatus });
|
|
const [override, setOverride] = useState<Step | null>(null);
|
|
|
|
const status = statusQuery.data;
|
|
const signedIn = Boolean(sessionQuery.data);
|
|
|
|
const step: Step = useMemo(() => {
|
|
if (override) return override;
|
|
if (!status) return 'account';
|
|
if (!status.has_users || !signedIn) return 'account';
|
|
if (!status.has_github_app) return 'github';
|
|
if (!status.has_tunnel) return 'tunnel';
|
|
return 'done';
|
|
}, [override, status, signedIn]);
|
|
|
|
const completeMutation = useMutation({
|
|
mutationFn: completeSetup,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['setup-status'] });
|
|
navigate('/projects', { replace: true });
|
|
},
|
|
});
|
|
|
|
if (statusQuery.isLoading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-primary)]">
|
|
<Loader2 size={22} className="animate-spin text-[var(--accent-primary)]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Wizard is optional once the instance is already configured.
|
|
if (status && !status.needs_setup && !override) {
|
|
return <Navigate to="/projects" replace />;
|
|
}
|
|
|
|
const steps: { key: Step; label: string }[] = [
|
|
{ key: 'account', label: 'Admin account' },
|
|
{ key: 'github', label: 'Git provider' },
|
|
{ key: 'tunnel', label: 'Public access' },
|
|
{ key: 'done', label: 'Done' },
|
|
];
|
|
const activeIdx = steps.findIndex((s) => s.key === step);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[var(--bg-primary)] flex flex-col items-center px-5 py-16">
|
|
<div className="w-full max-w-lg">
|
|
<div className="flex items-center gap-2 mb-8">
|
|
{steps.map((s, i) => (
|
|
<div key={s.key} className="flex items-center gap-2">
|
|
<div
|
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-semibold border ${
|
|
i < activeIdx
|
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-[var(--accent-on)]'
|
|
: i === activeIdx
|
|
? 'border-[var(--accent-primary)] text-[var(--accent-primary)]'
|
|
: 'border-[var(--border-subtle)] text-[var(--text-muted)]'
|
|
}`}
|
|
>
|
|
{i < activeIdx ? <Check size={12} /> : i + 1}
|
|
</div>
|
|
<span
|
|
className={`text-xs ${i === activeIdx ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]'}`}
|
|
>
|
|
{s.label}
|
|
</span>
|
|
{i < steps.length - 1 ? <div className="w-6 h-px bg-[var(--border-subtle)]" /> : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{step === 'account' ? (
|
|
<section className="panel p-6">
|
|
<h1 className="text-xl font-bold text-[var(--text-primary)]">Welcome to Containr</h1>
|
|
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
|
First boot detected. Create the admin account — registration closes to the public
|
|
right after, so this account owns the instance.
|
|
</p>
|
|
{status?.has_users && !signedIn ? (
|
|
<div className="mt-4">
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|
An account already exists — sign in to continue setup.
|
|
</p>
|
|
<Link
|
|
to="/auth/sign-in?redirect=/setup"
|
|
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]"
|
|
style={{ background: 'var(--accent-primary)' }}
|
|
>
|
|
Sign in <ChevronRight size={14} />
|
|
</Link>
|
|
</div>
|
|
) : !status?.has_users ? (
|
|
<Link
|
|
to="/auth/sign-up?redirect=/setup"
|
|
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]"
|
|
style={{ background: 'var(--accent-primary)' }}
|
|
>
|
|
Create admin account <ChevronRight size={14} />
|
|
</Link>
|
|
) : null}
|
|
</section>
|
|
) : null}
|
|
|
|
{step === 'github' ? <GitHubStep onNext={() => setOverride('tunnel')} /> : null}
|
|
{step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null}
|
|
|
|
{step === 'done' ? (
|
|
<section className="panel p-6 text-center">
|
|
<Rocket size={28} className="mx-auto text-[var(--accent-primary)]" />
|
|
<h1 className="mt-4 text-xl font-bold text-[var(--text-primary)]">All set</h1>
|
|
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
|
Deploy your first service from a git repo, an image, or a template — the dashboard is
|
|
ready.
|
|
</p>
|
|
{completeMutation.isError ? (
|
|
<p className="mt-3 text-xs text-[var(--error)]">
|
|
{completeMutation.error instanceof Error
|
|
? completeMutation.error.message
|
|
: 'Failed to save setup state'}
|
|
</p>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={() => completeMutation.mutate()}
|
|
disabled={completeMutation.isPending}
|
|
className="mt-5 inline-flex items-center gap-2 h-10 px-6 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
|
style={{ background: 'var(--accent-primary)' }}
|
|
>
|
|
{completeMutation.isPending ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<ShieldCheck size={15} />
|
|
)}
|
|
Enter Containr
|
|
</button>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GitHubStep({ onNext }: { onNext: () => void }) {
|
|
const queryClient = useQueryClient();
|
|
const provisionMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const { url, manifest } = await createGitHubAppManifest({
|
|
base_url: window.location.origin,
|
|
});
|
|
sessionStorage.setItem(SETUP_RETURN_KEY, '1');
|
|
const form = document.createElement('form');
|
|
form.method = 'POST';
|
|
form.action = url;
|
|
const input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = 'manifest';
|
|
input.value = manifest;
|
|
form.appendChild(input);
|
|
document.body.appendChild(form);
|
|
form.submit();
|
|
},
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['setup-status'] }),
|
|
});
|
|
|
|
return (
|
|
<section className="panel p-6">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<Github size={20} className="text-[var(--text-primary)]" />
|
|
<h1 className="text-xl font-bold text-[var(--text-primary)]">Connect a git provider</h1>
|
|
</div>
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|
Provision the instance GitHub App — repo push events then trigger deployments and preview
|
|
environments automatically. Self-hosted GitLab/Gitea can be added later in Settings.
|
|
</p>
|
|
{provisionMutation.isError ? (
|
|
<p className="mt-3 text-xs text-[var(--error)]">
|
|
{provisionMutation.error instanceof Error
|
|
? provisionMutation.error.message
|
|
: 'Could not start GitHub App provisioning'}
|
|
</p>
|
|
) : null}
|
|
<div className="mt-5 flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => provisionMutation.mutate()}
|
|
disabled={provisionMutation.isPending}
|
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
|
|
style={{ background: 'var(--accent-primary)' }}
|
|
>
|
|
{provisionMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
|
Set up GitHub App
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onNext}
|
|
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
>
|
|
Skip for now
|
|
</button>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function TunnelStep({ onNext }: { onNext: () => void }) {
|
|
const queryClient = useQueryClient();
|
|
const [token, setToken] = useState('');
|
|
const saveMutation = useMutation({
|
|
mutationFn: () =>
|
|
updatePlatformSettings({ cloudflareTunnelToken: token.trim() || undefined }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['setup-status'] });
|
|
onNext();
|
|
},
|
|
});
|
|
|
|
return (
|
|
<section className="panel p-6">
|
|
<h1 className="text-xl font-bold text-[var(--text-primary)]">Public access (optional)</h1>
|
|
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
|
Paste a Cloudflare Tunnel token to expose this instance on the internet. Without one,
|
|
Containr stays reachable on this network — you can add it later in Settings.
|
|
</p>
|
|
<input
|
|
type="password"
|
|
value={token}
|
|
onChange={(e) => setToken(e.target.value)}
|
|
placeholder="eyJhIjo… tunnel token"
|
|
className="mt-4 w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-colors"
|
|
/>
|
|
{saveMutation.isError ? (
|
|
<p className="mt-3 text-xs text-[var(--error)]">
|
|
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'}
|
|
</p>
|
|
) : null}
|
|
<div className="mt-5 flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => (token.trim() ? saveMutation.mutate() : onNext())}
|
|
disabled={saveMutation.isPending}
|
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
|
|
style={{ background: 'var(--accent-primary)' }}
|
|
>
|
|
{saveMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
|
{token.trim() ? 'Save tunnel token' : 'Continue'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onNext}
|
|
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
>
|
|
Skip
|
|
</button>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|