mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat: owner accounts and in-app platform settings
- First registered account becomes admin (users.is_admin, backfilled to earliest account); better-auth mirror path does the same - Public registration closes after bootstrap; admin can reopen it via GET/PUT /api/v1/settings (admin-gated) or SIGNUP_ENABLED env - In-app settings override env vars; secrets stored in app_settings, token values never returned by the API - Saving a Cloudflare Tunnel token pulls and runs a managed cloudflared sidecar on the backend's networks; clearing removes it; startup restores it from saved settings - group_add DOCKER_GID grants the non-root backend user Docker socket access; install.sh probes the in-container socket gid - Settings page gains an owner-only Platform section (registration toggle, tunnel token, link to Cloudflare dashboard); sign-up page stays reachable while registration is open - OpenAPI spec + generated types updated; manual user creation now requires admin Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
c3959d13e2
commit
c16b32f56a
18 files changed
+802
-34
No files matched your search
@@ -279,7 +279,8 @@ export function SignUpPage() {
|
||||
return <Navigate to={redirectPath} replace />;
|
||||
}
|
||||
|
||||
if (bootstrapQuery.data?.mode === 'login') {
|
||||
// Registration stays available when the owner reopened it in Settings.
|
||||
if (bootstrapQuery.data?.mode === 'login' && !bootstrapQuery.data.signupEnabled) {
|
||||
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />;
|
||||
}
|
||||
|
||||
@@ -302,7 +303,12 @@ export function SignUpPage() {
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap.">
|
||||
<AuthCard
|
||||
title="Create Account"
|
||||
subtitle={bootstrapQuery.data?.mode === 'register'
|
||||
? 'Create first platform owner account. Registration closes after bootstrap.'
|
||||
: 'Create a new account.'}
|
||||
>
|
||||
{error ? <AuthErrorNotice message={error} /> : null}
|
||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
|
||||
{bootstrapQuery.isError ? <AuthErrorNotice message="Cannot reach the Containr API - check that the backend container is running." /> : null}
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
getGitHubAppInstallUrl,
|
||||
connectGitHubApp,
|
||||
updateCurrentUserProfile,
|
||||
getPlatformSettings,
|
||||
updatePlatformSettings,
|
||||
getHAStatus,
|
||||
setHAEnabled,
|
||||
triggerFailover,
|
||||
@@ -92,6 +94,8 @@ import {
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
HeartPulse,
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
|
||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||
@@ -1077,6 +1081,144 @@ function GitProvidersSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformSettingsSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ['platform-settings'],
|
||||
queryFn: getPlatformSettings,
|
||||
});
|
||||
|
||||
const [signupDraft, setSignupDraft] = useState<boolean | null>(null);
|
||||
const [tokenDraft, setTokenDraft] = useState('');
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const signupEnabled = signupDraft ?? settingsQuery.data?.signupEnabled ?? false;
|
||||
const tunnel = settingsQuery.data?.cloudflareTunnel;
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: updatePlatformSettings,
|
||||
onSuccess: (settings) => {
|
||||
queryClient.setQueryData(['platform-settings'], settings);
|
||||
setSignupDraft(null);
|
||||
setTokenDraft('');
|
||||
setFeedback('Settings saved.');
|
||||
setError(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save settings');
|
||||
setFeedback(null);
|
||||
},
|
||||
});
|
||||
|
||||
const saveSignup = (enabled: boolean) => {
|
||||
setSignupDraft(enabled);
|
||||
saveMutation.mutate({ signupEnabled: enabled });
|
||||
};
|
||||
|
||||
const saveToken = () => {
|
||||
if (!tokenDraft.trim()) return;
|
||||
saveMutation.mutate({ cloudflareTunnelToken: tokenDraft.trim() });
|
||||
};
|
||||
|
||||
const clearToken = () => {
|
||||
saveMutation.mutate({ cloudflareTunnelToken: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<Cloud size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Platform</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Owner only - values set here override environment variables</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settingsQuery.isLoading ? (
|
||||
<div className="py-6 text-center">
|
||||
<Loader2 size={18} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start gap-3 p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={signupEnabled}
|
||||
onChange={(e) => saveSignup(e.target.checked)}
|
||||
disabled={saveMutation.isPending}
|
||||
className="mt-0.5 accent-[var(--accent-primary)]"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">Open registration</span>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">
|
||||
Let anyone with the URL create an account. Off by default after the owner account is created.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Cloudflare Tunnel</span>
|
||||
<span className="text-xs text-[var(--text-tertiary)]">
|
||||
{tunnel?.tokenSet
|
||||
? `token saved (${tunnel.source}) - ${tunnel.container}`
|
||||
: tunnel?.container === 'unavailable'
|
||||
? 'Docker socket unavailable'
|
||||
: 'not configured'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||
Expose this instance through a Cloudflare Tunnel. Create a tunnel in the dashboard,
|
||||
copy the token, paste it here - the cloudflared sidecar is managed for you.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={tokenDraft}
|
||||
onChange={(e) => setTokenDraft(e.target.value)}
|
||||
className="flex-1 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder={tunnel?.tokenSet ? 'Token saved - paste a new one to rotate' : 'eyJhIjo... tunnel token'}
|
||||
/>
|
||||
<button
|
||||
onClick={saveToken}
|
||||
disabled={saveMutation.isPending || !tokenDraft.trim()}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
{tunnel?.tokenSet && tunnel.source === 'app' ? (
|
||||
<button
|
||||
onClick={clearToken}
|
||||
disabled={saveMutation.isPending}
|
||||
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<a
|
||||
href="https://one.dash.cloudflare.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 mt-3 text-xs text-[var(--accent-primary)] hover:underline"
|
||||
>
|
||||
Open Cloudflare Zero Trust dashboard - Networks → Tunnels → Create
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{feedback ? <p className="text-xs text-[var(--success)]">{feedback}</p> : null}
|
||||
{error ? <p className="text-xs text-[var(--error)]">{error}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1238,6 +1380,9 @@ export function SettingsPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Platform Settings - owner only */}
|
||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||
|
||||
{/* Git Providers Section */}
|
||||
<GitProvidersSection />
|
||||
|
||||
|
||||
@@ -2153,7 +2153,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Bootstrap status
|
||||
* @description Reports whether the instance already has a registered user. Drives the first-run setup gate.
|
||||
* @description Reports whether the instance already has a registered user, which auth providers are configured, and whether public registration is open. Drives the first-run setup gate.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
@@ -2171,9 +2171,18 @@ export interface paths {
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @description True when no user exists yet */
|
||||
needs_setup?: boolean;
|
||||
/** @description True when at least one account exists */
|
||||
has_users?: boolean;
|
||||
user_count?: number;
|
||||
/**
|
||||
* @description register when the instance has no users, login otherwise
|
||||
* @enum {string}
|
||||
*/
|
||||
mode?: "register" | "login";
|
||||
/** @description OAuth providers with real credentials configured (e.g. github, google). Empty on self-hosted installs. */
|
||||
providers?: string[];
|
||||
/** @description True when the owner re-opened public registration in Settings */
|
||||
signup_enabled?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -2187,6 +2196,91 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/settings": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Read platform settings
|
||||
* @description Admin only. Returns in-app settings that override environment variables.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Current platform settings */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PlatformSettings"];
|
||||
};
|
||||
};
|
||||
/** @description Admin access required */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Update platform settings
|
||||
* @description Admin only. Provided keys are applied immediately; the Cloudflare tunnel token starts/stops the managed cloudflared sidecar.
|
||||
*/
|
||||
put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @description Allow new public account registration after bootstrap */
|
||||
signup_enabled?: boolean;
|
||||
/** @description Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container. */
|
||||
cloudflare_tunnel_token?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Updated platform settings */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PlatformSettings"];
|
||||
};
|
||||
};
|
||||
/** @description Admin access required */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -6687,6 +6781,21 @@ export interface components {
|
||||
/** @description User display name */
|
||||
name: string;
|
||||
};
|
||||
PlatformSettings: {
|
||||
/** @description Whether public account registration is open after bootstrap */
|
||||
signup_enabled?: boolean;
|
||||
cloudflare_tunnel?: {
|
||||
/** @description Whether a tunnel token is configured (value is never returned) */
|
||||
token_set?: boolean;
|
||||
/**
|
||||
* @description Where the active token comes from - in-app setting, env fallback, or unset
|
||||
* @enum {string}
|
||||
*/
|
||||
source?: "app" | "env" | "none";
|
||||
/** @description cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable */
|
||||
container?: string;
|
||||
};
|
||||
};
|
||||
User: {
|
||||
/** @description User ID */
|
||||
id?: string;
|
||||
@@ -6699,6 +6808,8 @@ export interface components {
|
||||
name?: string;
|
||||
/** @description Optional avatar image URL */
|
||||
avatar_url?: string;
|
||||
/** @description True for the platform owner (first registered account) */
|
||||
is_admin?: boolean;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Account creation timestamp
|
||||
|
||||
@@ -18,10 +18,20 @@ export type UserProfile = {
|
||||
email: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
isAdmin: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type PlatformSettings = {
|
||||
signupEnabled: boolean;
|
||||
cloudflareTunnel: {
|
||||
tokenSet: boolean;
|
||||
source: 'app' | 'env' | 'none';
|
||||
container: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ServiceEntity = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -442,11 +452,46 @@ function normalizeUserProfile(profile: RawUserProfile): UserProfile | null {
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
avatarUrl: profile.avatar_url ?? undefined,
|
||||
isAdmin: profile.is_admin ?? false,
|
||||
createdAt: profile.created_at ?? undefined,
|
||||
updatedAt: profile.updated_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlatformSettings(raw: components['schemas']['PlatformSettings']): PlatformSettings {
|
||||
return {
|
||||
signupEnabled: raw.signup_enabled ?? false,
|
||||
cloudflareTunnel: {
|
||||
tokenSet: raw.cloudflare_tunnel?.token_set ?? false,
|
||||
source: raw.cloudflare_tunnel?.source ?? 'none',
|
||||
container: raw.cloudflare_tunnel?.container ?? 'missing',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPlatformSettings(): Promise<PlatformSettings> {
|
||||
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings');
|
||||
return normalizePlatformSettings(raw);
|
||||
}
|
||||
|
||||
export async function updatePlatformSettings(input: {
|
||||
signupEnabled?: boolean;
|
||||
cloudflareTunnelToken?: string;
|
||||
}): Promise<PlatformSettings> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (input.signupEnabled !== undefined) {
|
||||
body.signup_enabled = input.signupEnabled;
|
||||
}
|
||||
if (input.cloudflareTunnelToken !== undefined) {
|
||||
body.cloudflare_tunnel_token = input.cloudflareTunnelToken;
|
||||
}
|
||||
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return normalizePlatformSettings(raw);
|
||||
}
|
||||
|
||||
function normalizeService(service: RawService): ServiceEntity | null {
|
||||
if (!service.id || !service.name || !service.project_id) {
|
||||
return null;
|
||||
|
||||
@@ -21,6 +21,7 @@ export type AuthBootstrap = {
|
||||
userCount: number;
|
||||
mode: 'register' | 'login';
|
||||
providers: string[];
|
||||
signupEnabled: boolean;
|
||||
};
|
||||
|
||||
export class AuthError extends Error {
|
||||
@@ -67,6 +68,7 @@ export async function getAuthBootstrap(): Promise<AuthBootstrap> {
|
||||
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
|
||||
mode: payload?.mode === 'register' ? 'register' : 'login',
|
||||
providers,
|
||||
signupEnabled: Boolean(payload?.signup_enabled),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user