mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-13 11:22:14 +02:00
feat(auth): gate OSS signup behind ENABLE_SIGNUP flag (#514)
* feat(auth): gate OSS signup behind ENABLE_SIGNUP flag
## Problem
The `POST /api/v1/auth/signup` endpoint is unconditionally exposed on
every OSS install. Operators running an invite-only deployment (private
customer instances, staging environments, internal-only tenants) have
no way to disable public account creation without patching the codebase.
The UI also shows the "Sign up" link on `/auth/login` regardless of
whether signup is available, so a locked-down deployment leaves broken
navigation on the login page.
## Fix
Introduce a single `ENABLE_SIGNUP` env var (default `true` — no behavior
change for existing installs) that controls signup end-to-end:
- **Backend** — `api/constants.ENABLE_SIGNUP` is read at module load.
The signup handler returns 403 when it's false. Also exposed on
`GET /api/v1/health` as `signup_enabled: bool` so the UI can mirror
the operator's choice at runtime instead of at bundle-build time.
- **UI** — `getSignupEnabled()` in `lib/auth/config.ts` proxies the
health field, `/api/config/auth` surfaces it to the browser, the
login page conditionally renders the "Sign up" link via a one-shot
`fetch("/api/config/auth")` in `useEffect`, and the middleware
redirects `/auth/signup` → `/auth/login` when disabled (fires before
Next.js can serve the statically-prerendered signup page).
- **Helm** — `config.enableSignup` (default `true`) is rendered into
the ConfigMap as `ENABLE_SIGNUP` so operators can flip it via
`--set config.enableSignup=false` at install/upgrade time.
Fallbacks default to `signupEnabled: true` in every layer so a fresh
install "just works" and matches the backend default.
* address review: rollout on ConfigMap change, cache TTL, no signup-link flash
Four review points on #514:
**P1 — ConfigMap Change Skips Rollout** (`configmap.yaml`). `helm upgrade
--set config.enableSignup=false` updated the ConfigMap but did NOT roll
the api pods, so running processes kept the ENABLE_SIGNUP env from
startup and continued serving the old signup behavior — including
divergence between replicas mid-upgrade.
Fix: add the standard `checksum/config` pod-template annotation on the
four backend Deployments that `envFrom` the ConfigMap (`web`,
`arq-worker`, `ari-manager`, `campaign-orchestrator`). Verified with
`helm template`: all four Deployments share the same checksum on any
given render, and flipping `config.enableSignup` changes the checksum
uniformly so kubectl sees a pod-template diff and rolls all four.
**P1 — Signup Flag Stays Cached (server)** (`ui/src/lib/auth/config.ts`).
Module-scoped cache had no TTL. `revalidate: 300` was passed on the
underlying `fetch()` but the in-memory short-circuit above ran first, so
the value never refreshed until the UI pod restarted.
Fix: add `AUTH_CONFIG_TTL_MS = 5 * 60 * 1000` (matching the fetch
revalidate hint) so the module cache and the Next fetch cache stay in
sync. Backend flag flips propagate within 5 minutes without a pod
restart.
**P1 — Middleware Redirect Uses Stale State** (`ui/src/middleware.ts`).
Same shape as above — a separate module cache with no expiry could keep
redirecting `/auth/signup → /auth/login` after signup was re-enabled, or
keep serving the statically-prerendered signup page after lockdown.
Fix: same `SERVER_CONFIG_TTL_MS = 5 * 60 * 1000` TTL on the middleware
cache.
**P2 — Signup link flash on login page** (`ui/src/app/auth/login/page.tsx`).
Initial `signupEnabled` state was `null`, so `{signupEnabled && ...}`
hid the link on first paint and it popped in after the fetch resolved
— a CLS on every login-page load on stock installs where signup is
enabled.
Fix: initialise the state to `true` (matches the backend default). The
fetch still overrides to `false` when the operator has actually
disabled signup, so the lockdown UI behavior is unchanged; only the
happy-path flash is gone.
* simplify signup flag: drop TTL caches and middleware redirect
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* resolve signup flag server-side to avoid signup link flicker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: prabhat pankaj <prabhatiitbhu@gmail.com>
Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2c803bbea9
commit
e7494e9c21
18 changed files with 184 additions and 101 deletions
|
|
@ -2,6 +2,9 @@
|
|||
ENVIRONMENT="local"
|
||||
LOG_LEVEL="DEBUG"
|
||||
|
||||
# Set to "false" to disable public signup (invite-only installs)
|
||||
ENABLE_SIGNUP="true"
|
||||
|
||||
# Change these values if you deploy the backend and frontend
|
||||
# on any hosting provider with some DNS. Please ensure to
|
||||
# provide the URL with scheme like http or https
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ CORS_ALLOWED_ORIGINS = [
|
|||
o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip()
|
||||
]
|
||||
AUTH_PROVIDER = os.getenv("AUTH_PROVIDER", "local")
|
||||
ENABLE_SIGNUP = os.getenv("ENABLE_SIGNUP", "true").lower() == "true"
|
||||
# Stack Auth public client config. These are safe to expose to the browser (the
|
||||
# publishable client key is public by design, and the project id is non-sensitive),
|
||||
# and are served to the UI at runtime via /api/v1/health so the frontend no longer
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from api.constants import ENABLE_SIGNUP
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.enums import OrganizationConfigurationKey, PostHogEvent
|
||||
|
|
@ -28,6 +29,9 @@ router = APIRouter(
|
|||
dependencies=[Depends(require_local_auth)],
|
||||
)
|
||||
async def signup(request: SignupRequest):
|
||||
if not ENABLE_SIGNUP:
|
||||
raise HTTPException(status_code=403, detail="Signup is disabled")
|
||||
|
||||
# Check if email is already taken
|
||||
existing_user = await db_client.get_user_by_email(request.email)
|
||||
if existing_user:
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ class HealthResponse(BaseModel):
|
|||
auth_provider: str
|
||||
turn_enabled: bool
|
||||
force_turn_relay: bool
|
||||
signup_enabled: bool
|
||||
# Public Stack Auth client config — only populated when auth_provider == "stack".
|
||||
# The UI reads these at runtime to initialize Stack, so they no longer need to
|
||||
# be baked into the browser bundle at build time. Both are public values.
|
||||
|
|
@ -93,6 +94,7 @@ async def health() -> HealthResponse:
|
|||
AUTH_PROVIDER,
|
||||
BACKEND_API_ENDPOINT,
|
||||
DEPLOYMENT_MODE,
|
||||
ENABLE_SIGNUP,
|
||||
FORCE_TURN_RELAY,
|
||||
STACK_AUTH_PROJECT_ID,
|
||||
STACK_PUBLISHABLE_CLIENT_KEY,
|
||||
|
|
@ -123,6 +125,7 @@ async def health() -> HealthResponse:
|
|||
auth_provider=AUTH_PROVIDER,
|
||||
turn_enabled=bool(TURN_SECRET),
|
||||
force_turn_relay=FORCE_TURN_RELAY,
|
||||
signup_enabled=ENABLE_SIGNUP,
|
||||
stack_project_id=STACK_AUTH_PROJECT_ID if is_stack else None,
|
||||
stack_publishable_client_key=(
|
||||
STACK_PUBLISHABLE_CLIENT_KEY if is_stack else None
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from types import SimpleNamespace
|
|||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import api.routes.auth as auth_routes
|
||||
from api.routes.auth import router
|
||||
from api.services.auth import depends as auth_depends
|
||||
from api.services.auth.depends import get_user
|
||||
|
|
@ -40,6 +41,23 @@ def test_stack_mode_hides_email_password_auth_routes(monkeypatch):
|
|||
assert login_response.json() == {"detail": "Not found"}
|
||||
|
||||
|
||||
def test_signup_disabled_returns_403(monkeypatch):
|
||||
monkeypatch.setattr(auth_routes, "ENABLE_SIGNUP", False)
|
||||
client = TestClient(_make_test_app())
|
||||
|
||||
response = client.post(
|
||||
"/auth/signup",
|
||||
json={
|
||||
"email": "user@example.com",
|
||||
"password": "password123",
|
||||
"name": "User",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Signup is disabled"}
|
||||
|
||||
|
||||
def test_stack_mode_keeps_current_user_route_available(monkeypatch):
|
||||
monkeypatch.setattr(auth_depends, "AUTH_PROVIDER", "stack")
|
||||
app = _make_test_app()
|
||||
|
|
|
|||
|
|
@ -25,10 +25,13 @@ spec:
|
|||
labels:
|
||||
{{- include "dograh.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: ari-manager
|
||||
{{- with .Values.ariManager.podAnnotations }}
|
||||
annotations:
|
||||
# Roll pods when the ConfigMap changes. See web-deployment.yaml for
|
||||
# the full rationale.
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
{{- with .Values.ariManager.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "dograh.serviceAccountName" . }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,13 @@ spec:
|
|||
labels:
|
||||
{{- include "dograh.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: arq-worker
|
||||
{{- with .Values.workers.podAnnotations }}
|
||||
annotations:
|
||||
# Roll pods when the ConfigMap changes. See web-deployment.yaml for
|
||||
# the full rationale.
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
{{- with .Values.workers.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "dograh.serviceAccountName" . }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
|
|
|
|||
|
|
@ -25,10 +25,13 @@ spec:
|
|||
labels:
|
||||
{{- include "dograh.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: campaign-orchestrator
|
||||
{{- with .Values.campaignOrchestrator.podAnnotations }}
|
||||
annotations:
|
||||
# Roll pods when the ConfigMap changes. See web-deployment.yaml for
|
||||
# the full rationale.
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
{{- with .Values.campaignOrchestrator.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "dograh.serviceAccountName" . }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ data:
|
|||
FORCE_TURN_RELAY: {{ .Values.config.forceTurnRelay | quote }}
|
||||
TURN_HOST: {{ .Values.config.turnHost | quote }}
|
||||
FASTAPI_WORKERS: {{ .Values.config.fastapiWorkers | quote }}
|
||||
ENABLE_SIGNUP: {{ .Values.config.enableSignup | quote }}
|
||||
{{- /* MinIO endpoints derived from storage mode. */ -}}
|
||||
{{- if eq .Values.storage.mode "internalMinio" }}
|
||||
MINIO_ENDPOINT: {{ printf "%s:9000" (include "dograh.minioHost" .) | quote }}
|
||||
|
|
|
|||
|
|
@ -24,10 +24,15 @@ spec:
|
|||
labels:
|
||||
{{- include "dograh.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: web
|
||||
{{- with .Values.web.podAnnotations }}
|
||||
annotations:
|
||||
# Roll pods when the ConfigMap changes (e.g. `helm upgrade --set
|
||||
# config.enableSignup=false`). envFrom values are otherwise only read
|
||||
# at pod startup, so a ConfigMap-only upgrade would leave running pods
|
||||
# on the stale value until an unrelated restart.
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
{{- with .Values.web.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "dograh.serviceAccountName" . }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ config:
|
|||
forceTurnRelay: false
|
||||
turnHost: "" # public hostname/IP of coturn (the LoadBalancer address)
|
||||
fastapiWorkers: 1 # informational only; web tier scales by pod, not in-pod workers
|
||||
enableSignup: true # set false to 403 the /api/v1/auth/signup endpoint (invite-only lockdown)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Secrets — rendered into a Kubernetes Secret unless secrets.existingSecret is
|
||||
|
|
|
|||
|
|
@ -147,6 +147,9 @@ services:
|
|||
ENVIRONMENT: "${ENVIRONMENT:-local}"
|
||||
LOG_LEVEL: "INFO"
|
||||
|
||||
# Set to "false" in .env to disable public signup (invite-only installs).
|
||||
ENABLE_SIGNUP: "${ENABLE_SIGNUP:-true}"
|
||||
|
||||
# Public origin for this deployment. The API derives BACKEND_API_ENDPOINT,
|
||||
# MINIO_PUBLIC_ENDPOINT and TURN_HOST from PUBLIC_BASE_URL / PUBLIC_HOST when
|
||||
# they are not set explicitly (see api/constants.py), so a standard remote
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ The relevant required variables for each mode are noted in the descriptions belo
|
|||
|---|---|---|
|
||||
| `OSS_JWT_SECRET` | N/A | **Required for OSS deployments.** Secret used to sign JWT tokens. Must be set to a strong random value in production |
|
||||
| `OSS_JWT_EXPIRY_HOURS` | `720` | JWT token lifetime in hours (default: 30 days) |
|
||||
| `ENABLE_SIGNUP` | `true` | Set to `false` to disable public signup on invite-only installs — `POST /api/v1/auth/signup` returns 403 and the login page hides the Sign up link |
|
||||
|
||||
<Warning>
|
||||
Never use the placeholder `OSS_JWT_SECRET` in a production deployment. Generate a strong random secret and store it securely.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthProvider, getStackConfig } from '@/lib/auth/config';
|
||||
import { getAuthProvider, getSignupEnabled, getStackConfig } from '@/lib/auth/config';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function GET() {
|
||||
|
|
@ -8,10 +8,12 @@ export async function GET() {
|
|||
// When using Stack, hand the public client config to the browser so it can
|
||||
// initialize the Stack SDK at runtime (no build-time NEXT_PUBLIC_* needed).
|
||||
const stackConfig = provider === 'stack' ? await getStackConfig() : null;
|
||||
const signupEnabled = await getSignupEnabled();
|
||||
logger.debug(`Got provider ${provider} from getAuthProvider`)
|
||||
return NextResponse.json({
|
||||
provider,
|
||||
stackProjectId: stackConfig?.projectId ?? null,
|
||||
stackPublishableClientKey: stackConfig?.publishableClientKey ?? null,
|
||||
signupEnabled,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
96
ui/src/app/auth/login/LoginForm.tsx
Normal file
96
ui/src/app/auth/login/LoginForm.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { loginApiV1AuthLoginPost } from "@/client/sdk.gen";
|
||||
import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA";
|
||||
import { AuthShell } from "@/components/auth/AuthShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function LoginForm({ signupEnabled }: { signupEnabled: boolean }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await loginApiV1AuthLoginPost({
|
||||
body: { email, password },
|
||||
});
|
||||
|
||||
if (res.error || !res.data) {
|
||||
const detail = (res.error as { detail?: string })?.detail;
|
||||
toast.error(detail || "Login failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set httpOnly cookies via server route
|
||||
await fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: res.data.token, user: res.data.user }),
|
||||
});
|
||||
|
||||
window.location.href = "/after-sign-in";
|
||||
} catch {
|
||||
toast.error("An error occurred. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
|
||||
<div className="space-y-1.5 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your email and password to continue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{signupEnabled && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/auth/signup" className="text-primary underline-offset-4 hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,94 +1,14 @@
|
|||
"use client";
|
||||
import { getSignupEnabled } from "@/lib/auth/config";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoginForm } from "./LoginForm";
|
||||
|
||||
import { loginApiV1AuthLoginPost } from "@/client/sdk.gen";
|
||||
import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA";
|
||||
import { AuthShell } from "@/components/auth/AuthShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
// Resolve the backend health check before rendering so the "Sign up" link is
|
||||
// correct on first paint — no client-side fetch, no flicker on locked-down
|
||||
// installs. force-dynamic keeps the page off the build-time prerender, which
|
||||
// would bake in the flag's build-environment value.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await loginApiV1AuthLoginPost({
|
||||
body: { email, password },
|
||||
});
|
||||
|
||||
if (res.error || !res.data) {
|
||||
const detail = (res.error as { detail?: string })?.detail;
|
||||
toast.error(detail || "Login failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set httpOnly cookies via server route
|
||||
await fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: res.data.token, user: res.data.user }),
|
||||
});
|
||||
|
||||
window.location.href = "/after-sign-in";
|
||||
} catch {
|
||||
toast.error("An error occurred. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
|
||||
<div className="space-y-1.5 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your email and password to continue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/auth/signup" className="text-primary underline-offset-4 hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</AuthShell>
|
||||
);
|
||||
export default async function LoginPage() {
|
||||
const signupEnabled = await getSignupEnabled();
|
||||
return <LoginForm signupEnabled={signupEnabled} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2884,6 +2884,10 @@ export type HealthResponse = {
|
|||
* Force Turn Relay
|
||||
*/
|
||||
force_turn_relay: boolean;
|
||||
/**
|
||||
* Signup Enabled
|
||||
*/
|
||||
signup_enabled: boolean;
|
||||
/**
|
||||
* Stack Project Id
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export interface StackConfig {
|
|||
interface ResolvedAuthConfig {
|
||||
authProvider: string;
|
||||
stackConfig: StackConfig | null;
|
||||
signupEnabled: boolean;
|
||||
}
|
||||
|
||||
let cachedConfig: ResolvedAuthConfig | null = null;
|
||||
|
|
@ -45,7 +46,10 @@ async function resolveAuthConfig(): Promise<ResolvedAuthConfig> {
|
|||
data.stack_publishable_client_key as string,
|
||||
}
|
||||
: null;
|
||||
cachedConfig = { authProvider, stackConfig };
|
||||
// Default to signup-enabled when the backend omits the field (older api
|
||||
// versions before the flag existed) — matches the backend's own default.
|
||||
const signupEnabled = data.signup_enabled !== false;
|
||||
cachedConfig = { authProvider, stackConfig, signupEnabled };
|
||||
return cachedConfig;
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -56,7 +60,7 @@ async function resolveAuthConfig(): Promise<ResolvedAuthConfig> {
|
|||
// do NOT cache it: caching here would pin the entire UI to local auth until a
|
||||
// container restart if the first resolution loses the startup race with the api
|
||||
// service. Leaving it uncached means the next request retries and self-heals.
|
||||
return { authProvider: "local", stackConfig: null };
|
||||
return { authProvider: "local", stackConfig: null, signupEnabled: true };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -73,3 +77,11 @@ export async function getAuthProvider(): Promise<string> {
|
|||
export async function getStackConfig(): Promise<StackConfig | null> {
|
||||
return (await resolveAuthConfig()).stackConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the backend allows signup (`ENABLE_SIGNUP`, default true).
|
||||
* The login page uses this to hide the signup link on locked-down installs.
|
||||
*/
|
||||
export async function getSignupEnabled(): Promise<boolean> {
|
||||
return (await resolveAuthConfig()).signupEnabled;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue