feat(invites): team invite links — admin create/revoke, public accept (#42)

* feat(branding): white-label — public /branding, admin write, shell theming

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(invites): team invite links — admin create/revoke, public accept

Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.

- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide

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:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-09 10:35:12 +02:00
1 parent b47ced0135
commit 50e39fe140
20 files changed
+1194 -4

No files matched your search

+2 -1
View File
@@ -24,7 +24,7 @@ import {
SecurityPage,
ComponentShowcase,
} from '@/features/secondary/pages';
import { SignInPage, SignUpPage } from '@/features/auth/pages';
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { getCurrentUserProfile } from '@/lib/api-client';
@@ -134,6 +134,7 @@ export default function App() {
<Route path="/" element={<LandingPage />} />
<Route path="/auth/sign-in" element={<SignInPage />} />
<Route path="/auth/sign-up" element={<SignUpPage />} />
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
{/* Public browsing — approved projects, templates, docs. */}
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
@@ -4,22 +4,27 @@ import { Link } from 'react-router-dom';
import {
CheckCircle2,
Clock,
Copy,
FolderKanban,
Megaphone,
Pencil,
ShieldCheck,
Trash2,
UserPlus,
Users,
XCircle,
} from 'lucide-react';
import {
createBanner,
createInvite,
deleteBanner,
deleteProject,
getAdminOverview,
listAdminBanners,
listAdminUsers,
listInvites,
listProjects,
revokeInvite,
setProjectApproval,
setUserAdmin,
updateBanner,
@@ -57,9 +62,21 @@ export function AdminPage() {
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
const bannersQuery = useQuery({ queryKey: ['admin-banners'], queryFn: listAdminBanners, enabled: !isDemoMode });
const invitesQuery = useQuery({
queryKey: ['admin-invites'],
queryFn: listInvites,
enabled: !isDemoMode,
select: (list) =>
list.map((invite) => ({
...invite,
expired: !invite.used_at && new Date(invite.expires_at).getTime() < Date.now(),
})),
});
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
const [editForm, setEditForm] = useState({ name: '', description: '' });
const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] });
const [inviteEmail, setInviteEmail] = useState('');
const [inviteLink, setInviteLink] = useState<string | null>(null);
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
@@ -67,9 +84,29 @@ export function AdminPage() {
queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
queryClient.invalidateQueries({ queryKey: ['admin-banners'] });
queryClient.invalidateQueries({ queryKey: ['active-banners'] });
queryClient.invalidateQueries({ queryKey: ['admin-invites'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
};
const inviteCreateMutation = useMutation({
mutationFn: () => createInvite({ email: inviteEmail.trim() || undefined }),
onSuccess: (result) => {
setInviteLink(`${window.location.origin}${result.url}`);
setInviteEmail('');
invalidate();
},
onError: (error) => showToast('error', 'Invite failed', error instanceof Error ? error.message : undefined),
});
const inviteRevokeMutation = useMutation({
mutationFn: (id: string) => revokeInvite(id),
onSuccess: () => {
showToast('success', 'Invite revoked');
invalidate();
},
onError: (error) => showToast('error', 'Revoke failed', error instanceof Error ? error.message : undefined),
});
const bannerCreateMutation = useMutation({
mutationFn: () =>
createBanner({
@@ -167,6 +204,7 @@ export function AdminPage() {
const users = usersQuery.data ?? [];
const allProjects = projectsQuery.data ?? [];
const banners = bannersQuery.data ?? [];
const invites = invitesQuery.data ?? [];
return (
<div className="mx-auto max-w-6xl px-5 py-8">
@@ -378,6 +416,97 @@ export function AdminPage() {
</div>
</section>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<UserPlus size={15} className="text-[var(--accent-primary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Team invites</h2>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Bind to email (optional)"
type="email"
className="h-9 min-w-64 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
<button
type="button"
onClick={() => inviteCreateMutation.mutate()}
disabled={inviteCreateMutation.isPending}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{inviteCreateMutation.isPending ? 'Creating…' : 'Create invite link'}
</button>
</div>
{inviteLink ? (
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--accent-primary-soft)] px-4 py-2.5">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(inviteLink);
showToast('success', 'Link copied');
}}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<Copy size={11} /> Copy
</button>
<button
type="button"
onClick={() => setInviteLink(null)}
className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
>
Dismiss
</button>
</div>
) : null}
{invites.map((invite) => {
const used = Boolean(invite.used_at);
const expired = !used && invite.expired;
return (
<div
key={invite.id}
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-[var(--text-primary)]">
{invite.email || 'Open invite'}
</p>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
expires {new Date(invite.expires_at).toLocaleString()}
</p>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
used
? 'bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
: expired
? 'bg-[var(--error-soft)] text-[var(--error)]'
: 'bg-[var(--success-soft)] text-[var(--success)]'
}`}
>
{used ? 'used' : expired ? 'expired' : 'open'}
</span>
{!used ? (
<button
type="button"
onClick={() => inviteRevokeMutation.mutate(invite.id)}
disabled={inviteRevokeMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
>
Revoke
</button>
) : null}
</div>
);
})}
{invites.length === 0 ? (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No invites yet.</p>
) : null}
</div>
</section>
<section className="mt-8 pb-10">
<div className="mb-3 flex items-center gap-2">
<Users size={15} className="text-[var(--accent-tertiary)]" />
+134
View File
@@ -11,6 +11,7 @@ import {
signUpWithEmail,
startGitHubSignIn,
} from '@/lib/auth-client';
import { acceptInvite, getInvite } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
function sanitizeRedirect(raw: string | null): string {
@@ -258,6 +259,139 @@ export function SignInPage() {
);
}
export function AcceptInvitePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const token = searchParams.get('token') ?? '';
const sessionQuery = useAuthSession();
const inviteQuery = useQuery({
queryKey: ['invite', token],
queryFn: () => getInvite(token),
enabled: token.length > 0,
retry: false,
});
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (sessionQuery.data) {
return <Navigate to="/projects" replace />;
}
const boundEmail = inviteQuery.data?.email ?? undefined;
const effectiveEmail = boundEmail ?? email;
const inviteValid = inviteQuery.data?.valid === true;
const submit = async (event: FormEvent) => {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await acceptInvite({ token, name: name.trim(), email: effectiveEmail.trim(), password });
// Establish the cookie session — the accept endpoint creates the
// account, sign-in creates the browser session.
await signInWithEmail(effectiveEmail.trim(), password);
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate('/projects', { replace: true });
} catch (exception) {
setError(exception instanceof Error ? exception.message : 'Failed to accept invite');
} finally {
setIsSubmitting(false);
}
};
if (!token) {
return (
<AuthLayout>
<AuthCard title="Invalid invite" subtitle="This link is missing its invite token.">
<AuthErrorNotice message="Ask your administrator for a fresh invite link." />
</AuthCard>
</AuthLayout>
);
}
return (
<AuthLayout>
<AuthCard
title="Join the team"
subtitle={boundEmail ? `Invited as ${boundEmail}` : 'You have been invited — create your account.'}
>
{error ? <AuthErrorNotice message={error} /> : null}
{inviteQuery.isLoading ? <AuthInfoNotice message="Validating invite…" /> : null}
{inviteQuery.isError ? <AuthErrorNotice message="This invite link is not valid." /> : null}
{inviteQuery.data && !inviteValid ? (
<AuthErrorNotice message={inviteQuery.data.used ? 'This invite was already used.' : 'This invite has expired.'} />
) : null}
{inviteValid ? (
<form className="space-y-3" onSubmit={submit}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Name
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="Your name"
/>
</label>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Email
<input
type="email"
value={boundEmail ?? email}
onChange={(e) => setEmail(e.target.value)}
disabled={Boolean(boundEmail)}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)] disabled:opacity-60"
placeholder="[email protected]"
/>
</label>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="At least 8 characters"
/>
</label>
<button
type="submit"
disabled={isSubmitting}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
>
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
Accept invite
</button>
</form>
) : null}
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
<span>Already have an account?</span>
<Link to="/auth/sign-in" className="inline-flex items-center gap-1 text-[var(--accent-primary)] hover:underline">
Sign in <ArrowRight size={12} />
</Link>
</div>
</AuthCard>
</AuthLayout>
);
}
export function SignUpPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
+210
View File
@@ -4015,6 +4015,216 @@ export interface paths {
};
trace?: never;
};
"/auth/invites/{token}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Validate an invite token (public) */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Invite state */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
valid?: boolean;
used?: boolean;
expired?: boolean;
email?: string | null;
/** Format: date-time */
expires_at?: string;
};
};
};
/** @description Invite not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/accept-invite": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Register via an invite link (public) */
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
token: string;
name: string;
email: string;
password: string;
};
};
};
responses: {
/** @description Created session */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LoginResponse"];
};
};
/** @description Invite used or expired */
410: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/invites": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List invites (admin) */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description All invites */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
invites?: Record<string, never>[];
};
};
};
};
};
put?: never;
/** Create an invite link (admin) */
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
/** @description Optionally bind the invite to this email */
email?: string;
/** @default 168 */
expires_in_hours?: number;
};
};
};
responses: {
/** @description Created invite with one-time token */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
invite?: Record<string, never>;
/** @description Shown once */
token?: string;
url?: string;
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/invites/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Revoke an invite (admin) */
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Revoked */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/branding": {
parameters: {
query?: never;
+1 -1
View File
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md",
"section": "guides",
"title": "Containr CLI",
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nup Line truncated
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nbanners list|creatLine truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
+49
View File
@@ -1972,6 +1972,55 @@ export async function deleteBanner(id: string): Promise<void> {
await requestJson(`/admin/banners/${id}`, { method: 'DELETE' });
}
export type Invite = {
id: string;
email?: string;
expires_at: string;
used_at?: string;
created_at?: string;
};
export type InviteState = {
valid: boolean;
used: boolean;
expired: boolean;
email?: string | null;
expires_at: string;
};
export async function getInvite(token: string): Promise<InviteState> {
return requestJson<InviteState>(`/auth/invites/${token}`);
}
export async function acceptInvite(input: {
token: string;
name: string;
email: string;
password: string;
}): Promise<void> {
await requestJson('/auth/accept-invite', { method: 'POST', body: JSON.stringify(input) });
}
export async function listInvites(): Promise<Invite[]> {
const payload = await requestJson<{ invites?: Invite[] }>('/admin/invites');
return payload.invites ?? [];
}
export async function createInvite(input: { email?: string; expiresInHours?: number }): Promise<{
invite: Invite;
token: string;
url: string;
}> {
const body: Record<string, unknown> = {};
if (input.email) body.email = input.email;
if (input.expiresInHours) body.expires_in_hours = input.expiresInHours;
return requestJson('/admin/invites', { method: 'POST', body: JSON.stringify(body) });
}
export async function revokeInvite(id: string): Promise<void> {
await requestJson(`/admin/invites/${id}`, { method: 'DELETE' });
}
export function serviceStatusClass(status: ServiceStatus): string {
switch (status) {
case 'running':