feat(auth): manage Google OAuth credentials from the database (epic 6b)
- Build the Google OAuth client lazily from sysconfig (DB override, env fallback) and re-register when the credentials change, so the install wizard / admin can set them without a restart. - New 'google' config group (google_client_id/secret, encrypted) on the Configuration page; the token-refresh reads the creds the same way. - Public GET /auth/config exposes google_enabled; the login page hides 'Continue with Google' when Google OAuth isn't configured (email+password still works).
This commit is contained in:
parent
f20375b5bd
commit
31046f905e
9 changed files with 106 additions and 24 deletions
|
|
@ -75,14 +75,40 @@ log = logging.getLogger("subfeed.auth")
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
oauth = OAuth()
|
# The Google OAuth client is built lazily from the CURRENT credentials (DB override via sysconfig,
|
||||||
oauth.register(
|
# else env), so the install wizard / admin can set or change them at runtime without a restart. The
|
||||||
|
# authlib registry is rebuilt only when the creds change (cheap, first use after a change).
|
||||||
|
_oauth = OAuth()
|
||||||
|
_oauth_creds: tuple[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def google_oauth(db: Session):
|
||||||
|
"""The configured authlib Google client, or None when no credentials are set (Google sign-in
|
||||||
|
is then disabled — email+password still works)."""
|
||||||
|
global _oauth, _oauth_creds
|
||||||
|
cid = sysconfig.get_str(db, "google_client_id")
|
||||||
|
csec = sysconfig.get_str(db, "google_client_secret")
|
||||||
|
if not cid or not csec:
|
||||||
|
return None
|
||||||
|
if _oauth_creds != (cid, csec):
|
||||||
|
_oauth = OAuth()
|
||||||
|
_oauth.register(
|
||||||
name="google",
|
name="google",
|
||||||
client_id=settings.google_client_id,
|
client_id=cid,
|
||||||
client_secret=settings.google_client_secret,
|
client_secret=csec,
|
||||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||||
client_kwargs={"scope": BASE_SCOPES},
|
client_kwargs={"scope": BASE_SCOPES},
|
||||||
)
|
)
|
||||||
|
_oauth_creds = (cid, csec)
|
||||||
|
return _oauth.google
|
||||||
|
|
||||||
|
|
||||||
|
def google_enabled(db: Session) -> bool:
|
||||||
|
"""Whether Sign in with Google is available (both client id + secret resolve to non-empty)."""
|
||||||
|
return bool(
|
||||||
|
sysconfig.get_str(db, "google_client_id")
|
||||||
|
and sysconfig.get_str(db, "google_client_secret")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def has_read_scope(user: User) -> bool:
|
def has_read_scope(user: User) -> bool:
|
||||||
|
|
@ -178,11 +204,16 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
async def login(request: Request):
|
async def login(request: Request, db: Session = Depends(get_db)):
|
||||||
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
||||||
# prompt=consent so returning users get a quick sign-in; the stored refresh token
|
# prompt=consent so returning users get a quick sign-in; the stored refresh token
|
||||||
# is kept when Google doesn't re-issue one (see the callback).
|
# is kept when Google doesn't re-issue one (see the callback).
|
||||||
return await oauth.google.authorize_redirect(
|
client = google_oauth(db)
|
||||||
|
if client is None:
|
||||||
|
# Google sign-in isn't configured on this instance — land back on the login page (the UI
|
||||||
|
# hides the button, so this is just a safety net for a stale/direct hit).
|
||||||
|
return RedirectResponse(url="/")
|
||||||
|
return await client.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
settings.oauth_redirect_url,
|
settings.oauth_redirect_url,
|
||||||
access_type="offline",
|
access_type="offline",
|
||||||
|
|
@ -198,8 +229,11 @@ async def callback(
|
||||||
background: BackgroundTasks,
|
background: BackgroundTasks,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
client = google_oauth(db)
|
||||||
|
if client is None:
|
||||||
|
return RedirectResponse(url="/")
|
||||||
try:
|
try:
|
||||||
token = await oauth.google.authorize_access_token(request)
|
token = await client.authorize_access_token(request)
|
||||||
except OAuthError as exc:
|
except OAuthError as exc:
|
||||||
raise HTTPException(status_code=400, detail=f"OAuth error: {exc.error}")
|
raise HTTPException(status_code=400, detail=f"OAuth error: {exc.error}")
|
||||||
|
|
||||||
|
|
@ -661,15 +695,22 @@ def require_human(user: User = Depends(current_user)) -> User:
|
||||||
|
|
||||||
|
|
||||||
@router.get("/link")
|
@router.get("/link")
|
||||||
async def link_google(request: Request, user: User = Depends(current_user)):
|
async def link_google(
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
"""Start linking a Google account to the signed-in (e.g. password) account. Requests only
|
"""Start linking a Google account to the signed-in (e.g. password) account. Requests only
|
||||||
the identity scopes — YouTube access is granted separately via /auth/upgrade. The callback
|
the identity scopes — YouTube access is granted separately via /auth/upgrade. The callback
|
||||||
sees `oauth_link_uid` and attaches the identity instead of creating a new account."""
|
sees `oauth_link_uid` and attaches the identity instead of creating a new account."""
|
||||||
if user.is_demo:
|
if user.is_demo:
|
||||||
return RedirectResponse(url="/")
|
return RedirectResponse(url="/")
|
||||||
|
client = google_oauth(db)
|
||||||
|
if client is None:
|
||||||
|
return RedirectResponse(url="/?link=error")
|
||||||
request.session["oauth_link_uid"] = user.id
|
request.session["oauth_link_uid"] = user.id
|
||||||
request.session["oauth_link_explicit"] = True
|
request.session["oauth_link_explicit"] = True
|
||||||
return await oauth.google.authorize_redirect(
|
return await client.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
settings.oauth_redirect_url,
|
settings.oauth_redirect_url,
|
||||||
access_type="offline",
|
access_type="offline",
|
||||||
|
|
@ -705,7 +746,10 @@ def set_password(
|
||||||
|
|
||||||
@router.get("/upgrade")
|
@router.get("/upgrade")
|
||||||
async def upgrade(
|
async def upgrade(
|
||||||
request: Request, access: str = "read", user: User = Depends(current_user)
|
request: Request,
|
||||||
|
access: str = "read",
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
|
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
|
||||||
read-only (enough to build the feed); `access=write` additionally grants management
|
read-only (enough to build the feed); `access=write` additionally grants management
|
||||||
|
|
@ -716,11 +760,14 @@ async def upgrade(
|
||||||
# straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
|
# straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
|
||||||
if user.is_demo:
|
if user.is_demo:
|
||||||
return RedirectResponse(url="/")
|
return RedirectResponse(url="/")
|
||||||
|
client = google_oauth(db)
|
||||||
|
if client is None:
|
||||||
|
return RedirectResponse(url="/")
|
||||||
# Attach the grant to THIS account in the callback. Without it a password account (no
|
# Attach the grant to THIS account in the callback. Without it a password account (no
|
||||||
# google_sub) would get a brand-new, separate Google user created instead of being linked.
|
# google_sub) would get a brand-new, separate Google user created instead of being linked.
|
||||||
request.session["oauth_link_uid"] = user.id
|
request.session["oauth_link_uid"] = user.id
|
||||||
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
|
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
|
||||||
return await oauth.google.authorize_redirect(
|
return await client.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
settings.oauth_redirect_url,
|
settings.oauth_redirect_url,
|
||||||
access_type="offline",
|
access_type="offline",
|
||||||
|
|
@ -739,3 +786,13 @@ async def me(user: User = Depends(current_user)) -> dict:
|
||||||
"avatar_url": user.avatar_url,
|
"avatar_url": user.avatar_url,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
def auth_config(db: Session = Depends(get_db)) -> dict:
|
||||||
|
"""Public: which sign-in options this instance offers, so the login page can hide what's off
|
||||||
|
(e.g. no Google button when Google OAuth isn't configured)."""
|
||||||
|
return {
|
||||||
|
"google_enabled": google_enabled(db),
|
||||||
|
"allow_registration": sysconfig.get_bool(db, "allow_registration"),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||||
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
|
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
|
||||||
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
|
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
|
||||||
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
|
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
|
||||||
|
# --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the
|
||||||
|
# install wizard / Configuration page so no restart is needed when the creds change. ---
|
||||||
|
ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True),
|
||||||
|
ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True),
|
||||||
# --- Access / registration ---
|
# --- Access / registration ---
|
||||||
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
|
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,8 @@ class YouTubeClient:
|
||||||
resp = self._http.post(
|
resp = self._http.post(
|
||||||
GOOGLE_TOKEN_URL,
|
GOOGLE_TOKEN_URL,
|
||||||
data={
|
data={
|
||||||
"client_id": settings.google_client_id,
|
"client_id": sysconfig.get_str(self.db, "google_client_id"),
|
||||||
"client_secret": settings.google_client_secret,
|
"client_secret": sysconfig.get_str(self.db, "google_client_secret"),
|
||||||
"refresh_token": refresh,
|
"refresh_token": refresh,
|
||||||
"grant_type": "refresh_token",
|
"grant_type": "refresh_token",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import Tabs, { usePersistedTab } from "./Tabs";
|
||||||
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
|
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
|
||||||
// the server until Save. Group order is fixed where known so logically related settings (e.g.
|
// the server until Save. Group order is fixed where known so logically related settings (e.g.
|
||||||
// Email/SMTP) stay together.
|
// Email/SMTP) stay together.
|
||||||
const GROUP_ORDER = ["access", "email", "youtube", "quota", "backfill", "shorts", "batch"];
|
const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch"];
|
||||||
type SaveState = "idle" | "saving" | "saved" | "error";
|
type SaveState = "idle" | "saving" | "saved" | "error";
|
||||||
|
|
||||||
export default function ConfigPanel() {
|
export default function ConfigPanel() {
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,13 @@ function AuthCard() {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
// Whether to offer "Continue with Google" — hidden when the instance has no Google OAuth
|
||||||
|
// configured. Optimistic (most instances have it); the rare Google-less instance just sees it
|
||||||
|
// disappear once the config loads.
|
||||||
|
const [googleEnabled, setGoogleEnabled] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
api.authConfig().then((c) => setGoogleEnabled(c.google_enabled)).catch(() => {});
|
||||||
|
}, []);
|
||||||
const [done, setDone] = useState<string>(""); // success/info message replacing the form
|
const [done, setDone] = useState<string>(""); // success/info message replacing the form
|
||||||
|
|
||||||
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
|
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
|
||||||
|
|
@ -423,12 +430,14 @@ function AuthCard() {
|
||||||
{t("welcome.auth.or")}
|
{t("welcome.auth.or")}
|
||||||
<span className="h-px flex-1 bg-border" />
|
<span className="h-px flex-1 bg-border" />
|
||||||
</div>
|
</div>
|
||||||
|
{googleEnabled && (
|
||||||
<a
|
<a
|
||||||
href="/auth/login"
|
href="/auth/login"
|
||||||
className="block text-center px-4 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent transition"
|
className="block text-center px-4 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent transition"
|
||||||
>
|
>
|
||||||
{t("welcome.auth.google")}
|
{t("welcome.auth.google")}
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
{!showDemo ? (
|
{!showDemo ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDemo(true)}
|
onClick={() => setShowDemo(true)}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"access": "Zugang",
|
"access": "Zugang",
|
||||||
"email": "E-Mail / SMTP",
|
"email": "E-Mail / SMTP",
|
||||||
"youtube": "YouTube-API",
|
"youtube": "YouTube-API",
|
||||||
|
"google": "Google-Anmeldung",
|
||||||
"quota": "Kontingent",
|
"quota": "Kontingent",
|
||||||
"backfill": "Nachladen (Backfill)",
|
"backfill": "Nachladen (Backfill)",
|
||||||
"shorts": "Shorts-Prüfung",
|
"shorts": "Shorts-Prüfung",
|
||||||
|
|
@ -25,6 +26,8 @@
|
||||||
"enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." },
|
"enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." },
|
||||||
"autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." },
|
"autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." },
|
||||||
"youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." },
|
"youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." },
|
||||||
|
"google_client_id": { "label": "Google-Client-ID", "hint": "OAuth-2.0-Client-ID für die Google-Anmeldung. Verschlüsselt gespeichert; nur schreibbar. Beide Google-Felder leer lassen, um die Google-Anmeldung zu deaktivieren (E-Mail+Passwort funktioniert weiter)." },
|
||||||
|
"google_client_secret": { "label": "Google-Client-Secret", "hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar." },
|
||||||
"allow_registration": { "label": "Registrierung erlauben", "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." }
|
"allow_registration": { "label": "Registrierung erlauben", "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." }
|
||||||
},
|
},
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"access": "Access",
|
"access": "Access",
|
||||||
"email": "Email / SMTP",
|
"email": "Email / SMTP",
|
||||||
"youtube": "YouTube API",
|
"youtube": "YouTube API",
|
||||||
|
"google": "Google sign-in",
|
||||||
"quota": "Quota",
|
"quota": "Quota",
|
||||||
"backfill": "Backfill",
|
"backfill": "Backfill",
|
||||||
"shorts": "Shorts probe",
|
"shorts": "Shorts probe",
|
||||||
|
|
@ -25,6 +26,8 @@
|
||||||
"enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." },
|
"enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." },
|
||||||
"autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." },
|
"autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." },
|
||||||
"youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." },
|
"youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." },
|
||||||
|
"google_client_id": { "label": "Google client ID", "hint": "OAuth 2.0 client ID for Sign in with Google. Stored encrypted; write-only. Leave both Google fields empty to disable Google sign-in (email+password still works)." },
|
||||||
|
"google_client_secret": { "label": "Google client secret", "hint": "OAuth 2.0 client secret. Stored encrypted; write-only." },
|
||||||
"allow_registration": { "label": "Allow registration", "hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in." }
|
"allow_registration": { "label": "Allow registration", "hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in." }
|
||||||
},
|
},
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"access": "Hozzáférés",
|
"access": "Hozzáférés",
|
||||||
"email": "E-mail / SMTP",
|
"email": "E-mail / SMTP",
|
||||||
"youtube": "YouTube API",
|
"youtube": "YouTube API",
|
||||||
|
"google": "Google bejelentkezés",
|
||||||
"quota": "Kvóta",
|
"quota": "Kvóta",
|
||||||
"backfill": "Letöltés (backfill)",
|
"backfill": "Letöltés (backfill)",
|
||||||
"shorts": "Shorts-vizsgálat",
|
"shorts": "Shorts-vizsgálat",
|
||||||
|
|
@ -25,6 +26,8 @@
|
||||||
"enrich_batch_size": { "label": "Gazdagítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." },
|
"enrich_batch_size": { "label": "Gazdagítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." },
|
||||||
"autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." },
|
"autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." },
|
||||||
"youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." },
|
"youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." },
|
||||||
|
"google_client_id": { "label": "Google kliens-azonosító", "hint": "OAuth 2.0 kliens-azonosító a Google-bejelentkezéshez. Titkosítva tárolva; csak írható. Hagyd üresen mindkét Google-mezőt a Google-bejelentkezés kikapcsolásához (az e-mail+jelszó továbbra is működik)." },
|
||||||
|
"google_client_secret": { "label": "Google kliens-titok", "hint": "OAuth 2.0 kliens-titok. Titkosítva tárolva; csak írható." },
|
||||||
"allow_registration": { "label": "Regisztráció engedélyezése", "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." }
|
"allow_registration": { "label": "Regisztráció engedélyezése", "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." }
|
||||||
},
|
},
|
||||||
"save": "Mentés",
|
"save": "Mentés",
|
||||||
|
|
|
||||||
|
|
@ -635,6 +635,9 @@ export const api = {
|
||||||
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
|
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Public: which sign-in options this instance offers (e.g. hide Google when not configured).
|
||||||
|
authConfig: (): Promise<{ google_enabled: boolean; allow_registration: boolean }> =>
|
||||||
|
req("/auth/config"),
|
||||||
// --- auth: email + password (errors handled inline via quiet) ---
|
// --- auth: email + password (errors handled inline via quiet) ---
|
||||||
register: (email: string, password: string): Promise<{ status: string }> =>
|
register: (email: string, password: string): Promise<{ status: string }> =>
|
||||||
req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
|
req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue