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 (#71)
* 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>
This commit is contained in:
1 parent
985d22798c
commit
c4307034ec
11 files changed
+622
-4
No files matched your search
+6
-2
@@ -537,8 +537,12 @@ and wired refs, one click or one CLI call.
|
||||
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
|
||||
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
|
||||
webhook table tests, Playwright e2e golden path.
|
||||
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
|
||||
Phase G makes this self-serve) → Tunnel → done.
|
||||
- [x] **First-run wizard** — public `GET /setup/status` + authed
|
||||
`POST /setup/complete`; `/setup` page steps admin account → GitHub App
|
||||
manifest flow (callback returns to the wizard via a session marker) →
|
||||
optional Cloudflare tunnel token → done. Shell redirects signed-in
|
||||
users to `/setup` while `needs_setup`; installs with existing projects
|
||||
auto-complete so nobody gets nagged retroactively.
|
||||
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
|
||||
|
||||
## 11. Deliberately not imported from dflow
|
||||
|
||||
@@ -286,6 +286,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
public.POST("/auth/register", handleRegister)
|
||||
public.GET("/maintenance", handleMaintenancePage)
|
||||
public.GET("/branding", handleGetBranding)
|
||||
public.GET("/setup/status", handleSetupStatus)
|
||||
|
||||
// Invite accept flow — validates the link, then registers
|
||||
// even while public signup is closed.
|
||||
@@ -355,6 +356,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
{
|
||||
authed.GET("/user/profile", handleGetProfile)
|
||||
authed.PUT("/user/profile", handleUpdateProfile)
|
||||
authed.POST("/setup/complete", handleSetupComplete)
|
||||
|
||||
// Personal access tokens for CLI/MCP/agent auth.
|
||||
authed.GET("/user/tokens", handleListUserTokens)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
// First-run setup wizard state. GET /setup/status is public so the web
|
||||
// shell can redirect fresh installs before a session exists; the flag
|
||||
// lives in app_settings so it survives restarts.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"containr/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const settingSetupCompleted = "setup_completed"
|
||||
|
||||
// setupComplete resolves the flag. Installs that predate the wizard
|
||||
// (users + projects already exist) auto-complete — nobody who has been
|
||||
// running Containr for months should get a welcome screen.
|
||||
func setupComplete(db *database.DB, hasUsers bool) bool {
|
||||
if strings.EqualFold(settingValue(db, settingSetupCompleted, "", ""), "true") {
|
||||
return true
|
||||
}
|
||||
if !hasUsers {
|
||||
return false
|
||||
}
|
||||
var projects int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err == nil && projects > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GET /api/v1/setup/status — public: fresh installs hit it before any
|
||||
// session exists.
|
||||
func handleSetupStatus(c *gin.Context) {
|
||||
db, ok := c.MustGet("db").(*database.DB)
|
||||
if !ok {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available")
|
||||
return
|
||||
}
|
||||
count, err := countLocalUsers(db)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to inspect setup state")
|
||||
return
|
||||
}
|
||||
hasUsers := count > 0
|
||||
completed := setupComplete(db, hasUsers)
|
||||
tunnelToken, _ := resolvedTunnelToken(db)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"has_users": hasUsers,
|
||||
"setup_completed": completed,
|
||||
"needs_setup": !completed,
|
||||
"has_github_app": githubAppConfigured(db),
|
||||
"has_tunnel": tunnelToken != "",
|
||||
"signup_enabled": signupEnabled(db),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/setup/complete — marks the wizard done. Open to any
|
||||
// authenticated user: the first account is the owner, and the flag only
|
||||
// suppresses a banner.
|
||||
func handleSetupComplete(c *gin.Context) {
|
||||
if _, ok := requireAuthenticatedUserID(c); !ok {
|
||||
return
|
||||
}
|
||||
db, ok := c.MustGet("db").(*database.DB)
|
||||
if !ok {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available")
|
||||
return
|
||||
}
|
||||
if err := setSetting(db, settingSetupCompleted, "true", false); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to save setup state")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Setup completed"})
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@/features/secondary/pages';
|
||||
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
|
||||
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
|
||||
import { SetupWizardPage } from '@/features/setup/SetupWizardPage';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||
@@ -138,6 +139,7 @@ export default function App() {
|
||||
<Route path="/auth/sign-in" element={<SignInPage />} />
|
||||
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
||||
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
|
||||
<Route path="/setup" element={<SetupWizardPage />} />
|
||||
|
||||
{/* Public browsing — approved projects, templates, docs. */}
|
||||
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { NavLink, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getCurrentUserProfile,
|
||||
getUpgradeStatus,
|
||||
pingServer,
|
||||
getSetupStatus,
|
||||
listNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
@@ -185,6 +186,13 @@ export function PlatformShell() {
|
||||
enabled: !isDemoMode && signedIn,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
// Fresh installs get routed through the setup wizard until the flag lands.
|
||||
const setupQuery = useQuery({
|
||||
queryKey: ['setup-status'],
|
||||
queryFn: getSetupStatus,
|
||||
enabled: !isDemoMode && signedIn,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: (id: string) => markNotificationRead(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
|
||||
@@ -247,6 +255,10 @@ export function PlatformShell() {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell min-h-screen">
|
||||
<div className="ambient-glow" />
|
||||
|
||||
@@ -1253,7 +1253,11 @@ export function GitHubAppCallbackPage() {
|
||||
mutationFn: (appCode: string) => convertGitHubAppCode(appCode),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['github-app-status'] });
|
||||
navigate('/settings', { replace: true });
|
||||
// The setup wizard stashes a return marker before submitting the
|
||||
// manifest form — resume there instead of the settings page.
|
||||
const inSetup = sessionStorage.getItem('containr.setup_return') === '1';
|
||||
sessionStorage.removeItem('containr.setup_return');
|
||||
navigate(inSetup ? '/setup' : '/settings', { replace: true });
|
||||
},
|
||||
});
|
||||
const firedRef = useRef(false);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { SetupWizardPage } from './SetupWizardPage';
|
||||
import type { SetupStatus } from '@/lib/api-client';
|
||||
|
||||
const mockStatus = vi.fn<() => Promise<SetupStatus>>();
|
||||
const mockSession = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api-client', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('@/lib/api-client')>();
|
||||
return {
|
||||
...mod,
|
||||
getSetupStatus: () => mockStatus(),
|
||||
completeSetup: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/lib/use-auth-session', () => ({
|
||||
useAuthSession: () => mockSession(),
|
||||
}));
|
||||
|
||||
function renderWizard() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/setup']}>
|
||||
<SetupWizardPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const base: SetupStatus = {
|
||||
has_users: false,
|
||||
setup_completed: false,
|
||||
needs_setup: true,
|
||||
has_github_app: false,
|
||||
has_tunnel: false,
|
||||
signup_enabled: false,
|
||||
};
|
||||
|
||||
describe('SetupWizardPage', () => {
|
||||
beforeEach(() => {
|
||||
mockSession.mockReturnValue({ data: null });
|
||||
});
|
||||
|
||||
it('prompts for the admin account on a fresh install', async () => {
|
||||
mockStatus.mockResolvedValue({ ...base });
|
||||
renderWizard();
|
||||
expect(await screen.findByText(/welcome to containr/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/create admin account/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers sign-in when users exist but nobody is signed in', async () => {
|
||||
mockStatus.mockResolvedValue({ ...base, has_users: true });
|
||||
renderWizard();
|
||||
expect(await screen.findByText(/sign in to continue setup/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the git provider step for a signed-in admin without GitHub App', async () => {
|
||||
mockStatus.mockResolvedValue({ ...base, has_users: true });
|
||||
mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
|
||||
renderWizard();
|
||||
expect(await screen.findByText(/connect a git provider/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/set up github app/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the tunnel step when git is already wired', async () => {
|
||||
mockStatus.mockResolvedValue({ ...base, has_users: true, has_github_app: true });
|
||||
mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
|
||||
renderWizard();
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: /public access/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lands on the finish step when everything is configured', async () => {
|
||||
mockStatus.mockResolvedValue({
|
||||
...base,
|
||||
has_users: true,
|
||||
has_github_app: true,
|
||||
has_tunnel: true,
|
||||
});
|
||||
mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
|
||||
renderWizard();
|
||||
expect(await screen.findByText(/enter containr/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -5633,6 +5633,90 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/setup/status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* First-run setup status
|
||||
* @description Public. Drives the setup wizard — needs_setup is true until setup_completed is recorded. Instances with users and projects that predate the wizard report completed automatically.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Setup state */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
has_users?: boolean;
|
||||
setup_completed?: boolean;
|
||||
needs_setup?: boolean;
|
||||
/** @description GitHub App credentials configured (manifest flow or env) */
|
||||
has_github_app?: boolean;
|
||||
/** @description Cloudflare tunnel token configured */
|
||||
has_tunnel?: boolean;
|
||||
signup_enabled?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/setup/complete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Mark the setup wizard complete */
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Setup completed */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/settings": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -499,6 +499,23 @@ export async function pingServer(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export type SetupStatus = {
|
||||
has_users: boolean;
|
||||
setup_completed: boolean;
|
||||
needs_setup: boolean;
|
||||
has_github_app: boolean;
|
||||
has_tunnel: boolean;
|
||||
signup_enabled: boolean;
|
||||
};
|
||||
|
||||
export async function getSetupStatus(): Promise<SetupStatus> {
|
||||
return requestJson<SetupStatus>('/setup/status');
|
||||
}
|
||||
|
||||
export async function completeSetup(): Promise<void> {
|
||||
await requestJson('/setup/complete', { method: 'POST', body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
export function getAgentPublicBaseUrl(): string {
|
||||
return `${API_ROOT}/api/agents`;
|
||||
}
|
||||
|
||||
@@ -3705,6 +3705,50 @@ paths:
|
||||
type: boolean
|
||||
description: True when the owner re-opened public registration in Settings
|
||||
|
||||
/setup/status:
|
||||
get:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: First-run setup status
|
||||
description: Public. Drives the setup wizard — needs_setup is true until setup_completed is recorded. Instances with users and projects that predate the wizard report completed automatically.
|
||||
responses:
|
||||
'200':
|
||||
description: Setup state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
has_users:
|
||||
type: boolean
|
||||
setup_completed:
|
||||
type: boolean
|
||||
needs_setup:
|
||||
type: boolean
|
||||
has_github_app:
|
||||
type: boolean
|
||||
description: GitHub App credentials configured (manifest flow or env)
|
||||
has_tunnel:
|
||||
type: boolean
|
||||
description: Cloudflare tunnel token configured
|
||||
signup_enabled:
|
||||
type: boolean
|
||||
|
||||
/setup/complete:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Mark the setup wizard complete
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Setup completed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MessageResponse'
|
||||
|
||||
/settings:
|
||||
get:
|
||||
tags:
|
||||
|
||||
Reference in new issue
Block a user