diff --git a/dashboard/src/features/auth/api.test.ts b/dashboard/src/features/auth/api.test.ts new file mode 100644 index 0000000..9a4e8e9 --- /dev/null +++ b/dashboard/src/features/auth/api.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { oauthStartUrl } from "./api"; + +describe("oauthStartUrl", () => { + it("returns the slot-rooted path when no next is supplied", () => { + expect(oauthStartUrl("google")).toBe("/api/auth/oauth/start/google"); + }); + + it("URL-encodes the next path so it survives querystring parsing", () => { + expect(oauthStartUrl("google", "/jobs?status=failed")).toBe( + "/api/auth/oauth/start/google?next=%2Fjobs%3Fstatus%3Dfailed", + ); + }); + + it("URL-encodes provider slots that contain reserved characters", () => { + // OIDC slot names must match ^[a-z][a-z0-9_-]{0,31}$ at the server, + // so this is defence-in-depth — but the encoding must not break the + // slot regex on the way out. + expect(oauthStartUrl("acme-okta", "/")).toBe("/api/auth/oauth/start/acme-okta?next=%2F"); + }); + + it("ignores empty / undefined next gracefully", () => { + expect(oauthStartUrl("github", undefined)).toBe("/api/auth/oauth/start/github"); + expect(oauthStartUrl("github", "")).toBe("/api/auth/oauth/start/github"); + }); +}); diff --git a/dashboard/src/features/auth/api.ts b/dashboard/src/features/auth/api.ts index b7d19c5..c4ae22a 100644 --- a/dashboard/src/features/auth/api.ts +++ b/dashboard/src/features/auth/api.ts @@ -1,10 +1,32 @@ import { api } from "@/lib/api-client"; -import type { AuthStatus, LoginResponse, SetupResponse, WhoamiResponse } from "./types"; +import type { + AuthStatus, + LoginResponse, + ProvidersResponse, + SetupResponse, + WhoamiResponse, +} from "./types"; export function fetchAuthStatus(signal?: AbortSignal): Promise { return api.get("/api/auth/status", { signal }); } +export function fetchProviders(signal?: AbortSignal): Promise { + return api.get("/api/auth/providers", { signal }); +} + +/** Browser URL the user is sent to when they click an OAuth provider button. + * + * The server's ``/api/auth/oauth/start/{slot}`` endpoint will mint state and + * 302 to the provider. We append ``next`` so the post-login callback can + * land the user back where they were trying to go. + */ +export function oauthStartUrl(slot: string, next?: string): string { + const base = `/api/auth/oauth/start/${encodeURIComponent(slot)}`; + if (!next) return base; + return `${base}?next=${encodeURIComponent(next)}`; +} + export function fetchWhoami(signal?: AbortSignal): Promise { return api.get("/api/auth/whoami", { signal }); } diff --git a/dashboard/src/features/auth/components/login-form.tsx b/dashboard/src/features/auth/components/login-form.tsx index 3038d80..1485775 100644 --- a/dashboard/src/features/auth/components/login-form.tsx +++ b/dashboard/src/features/auth/components/login-form.tsx @@ -1,10 +1,11 @@ -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { AlertCircle, LogIn } from "lucide-react"; import { type FormEvent, useState } from "react"; import { Button } from "@/components/ui"; import { Input } from "@/components/ui/input"; import { ApiError } from "@/lib/api-client"; -import { useLogin } from "../hooks"; +import { useAuthProviders, useLogin } from "../hooks"; +import { OAuthButton } from "./oauth-button"; const ERROR_MESSAGES: Record = { invalid_credentials: "Invalid username or password.", @@ -13,9 +14,19 @@ const ERROR_MESSAGES: Record = { export function LoginForm() { const navigate = useNavigate(); + const search = useSearch({ strict: false }) as { next?: string } | undefined; + const nextPath = typeof search?.next === "string" ? search.next : undefined; + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const login = useLogin(); + const providers = useAuthProviders(); + + // Default to password-on while the providers query is in flight so the + // form doesn't flash empty on the first render. + const passwordEnabled = providers.data?.password_enabled ?? true; + const oauthProviders = providers.data?.providers ?? []; + const hasOAuth = oauthProviders.length > 0; function onSubmit(event: FormEvent): void { event.preventDefault(); @@ -23,7 +34,7 @@ export function LoginForm() { { username, password }, { onSuccess: () => { - void navigate({ to: "/" }); + void navigate({ to: nextPath ?? "/" }); }, }, ); @@ -33,52 +44,86 @@ export function LoginForm() { const disabled = login.isPending || !username || !password; return ( -
+

Sign in

- Enter your dashboard credentials to continue. + {passwordEnabled + ? "Enter your dashboard credentials to continue." + : "Choose a provider to continue."}

- - - {error ? ( + + {hasOAuth ? ( +
+ {oauthProviders.map((provider) => ( + + ))} +
+ ) : null} + + {hasOAuth && passwordEnabled ? ( +
+
+ or sign in with password +
+
+ ) : null} + + {passwordEnabled ? ( + + + + {error ? ( +
+ + {error} +
+ ) : null} + + + ) : null} + + {!passwordEnabled && !hasOAuth ? (
- {error} + + No login methods are configured. Set{" "} + TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=true or configure an OAuth + provider. +
) : null} - - +
); } diff --git a/dashboard/src/features/auth/components/oauth-button.tsx b/dashboard/src/features/auth/components/oauth-button.tsx new file mode 100644 index 0000000..e5f53bc --- /dev/null +++ b/dashboard/src/features/auth/components/oauth-button.tsx @@ -0,0 +1,85 @@ +import { KeyRound } from "lucide-react"; +import { oauthStartUrl } from "../api"; +import type { AuthProvider } from "../types"; + +interface OAuthButtonProps { + provider: AuthProvider; + /** Path to send the user to after a successful login. Validated server-side. */ + next?: string; +} + +/** "Sign in with X" button — renders as a plain anchor so the browser + * follows the 302 from ``/api/auth/oauth/start/{slot}`` natively. + * + * Styling matches the dashboard's design system without depending on the + * primary :class:`Button` component (we need anchor semantics, not button). + */ +export function OAuthButton({ provider, next }: OAuthButtonProps) { + return ( + + + Continue with {provider.label} + + ); +} + +function ProviderIcon({ type }: { type: AuthProvider["type"] }) { + if (type === "google") { + return ; + } + if (type === "github") { + return ; + } + // Generic OIDC — operator-configured SSO. + return ; +} + +/** Official Google "G" mark — inlined SVG so we don't pull in a brand-asset + * dependency. Matches Google's brand guidelines for sign-in buttons. + */ +function GoogleGlyph() { + return ( + + Google + + + + + + ); +} + +/** GitHub Octocat (Mark) — inlined so we don't depend on a brand-icon set + * that might drop it (lucide-react 1.x removed brand icons). + */ +function GitHubGlyph() { + return ( + + GitHub + + + ); +} diff --git a/dashboard/src/features/auth/hooks.ts b/dashboard/src/features/auth/hooks.ts index 449ed44..b1481a3 100644 --- a/dashboard/src/features/auth/hooks.ts +++ b/dashboard/src/features/auth/hooks.ts @@ -1,10 +1,19 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiError } from "@/lib/api-client"; -import { changePassword, fetchAuthStatus, fetchWhoami, login, logout, setup } from "./api"; +import { + changePassword, + fetchAuthStatus, + fetchProviders, + fetchWhoami, + login, + logout, + setup, +} from "./api"; import type { WhoamiResponse } from "./types"; export const AUTH_STATUS_KEY = ["auth", "status"] as const; export const WHOAMI_KEY = ["auth", "whoami"] as const; +export const PROVIDERS_KEY = ["auth", "providers"] as const; export function authStatusQuery() { return queryOptions({ @@ -42,6 +51,15 @@ export function whoamiQuery() { }); } +/** List of OAuth providers exposed by the server. */ +export function providersQuery() { + return queryOptions({ + queryKey: PROVIDERS_KEY, + queryFn: ({ signal }) => fetchProviders(signal), + staleTime: 60_000, + }); +} + export function useAuthStatus() { return useQuery(authStatusQuery()); } @@ -50,6 +68,10 @@ export function useWhoami() { return useQuery(whoamiQuery()); } +export function useAuthProviders() { + return useQuery(providersQuery()); +} + export function useLogin() { const qc = useQueryClient(); return useMutation({ diff --git a/dashboard/src/features/auth/index.ts b/dashboard/src/features/auth/index.ts index 259cdc0..40babe4 100644 --- a/dashboard/src/features/auth/index.ts +++ b/dashboard/src/features/auth/index.ts @@ -1,9 +1,12 @@ export { AuthGate } from "./components/auth-gate"; export { LoginForm } from "./components/login-form"; +export { OAuthButton } from "./components/oauth-button"; export { SetupForm } from "./components/setup-form"; export { UserMenu } from "./components/user-menu"; export { authStatusQuery, + providersQuery, + useAuthProviders, useAuthStatus, useChangePassword, useLogin, @@ -13,10 +16,12 @@ export { whoamiQuery, } from "./hooks"; export type { + AuthProvider, AuthSession, AuthStatus, AuthUser, LoginResponse, + ProvidersResponse, SetupResponse, WhoamiResponse, } from "./types"; diff --git a/dashboard/src/features/auth/types.ts b/dashboard/src/features/auth/types.ts index 4596f6e..e61b632 100644 --- a/dashboard/src/features/auth/types.ts +++ b/dashboard/src/features/auth/types.ts @@ -30,3 +30,18 @@ export interface WhoamiResponse { csrf_token: string; expires_at: number; } + +/** One entry in the providers listing response. */ +export interface AuthProvider { + /** Stable URL-safe identifier used in the callback path. */ + slot: string; + /** Human-readable button label. */ + label: string; + /** Provider type, drives which icon is rendered. */ + type: "google" | "github" | "oidc"; +} + +export interface ProvidersResponse { + password_enabled: boolean; + providers: AuthProvider[]; +} diff --git a/docs/content/docs/guides/observability/dashboard-auth.mdx b/docs/content/docs/guides/observability/dashboard-auth.mdx index 3823768..9596477 100644 --- a/docs/content/docs/guides/observability/dashboard-auth.mdx +++ b/docs/content/docs/guides/observability/dashboard-auth.mdx @@ -146,13 +146,18 @@ Set `TASKITO_WEBHOOKS_ALLOW_PRIVATE=1` to disable the guard for local development against `http://localhost`. Production should keep the guard on. +## SSO / OAuth login + +Native sign-in with Google, GitHub, and any OIDC-compliant provider +(Okta, Auth0, Keycloak, Microsoft Entra) is available alongside +password auth — see [Dashboard SSO (OAuth & OIDC)](./dashboard-oauth). +Operators can mix-and-match providers or run an OAuth-only deployment +by setting `TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=false`. + ## Limitations - **One role** today (`admin`). Read-only viewers and per-route permissions are planned; the column already exists on the user record. -- **No SSO / OIDC** out of the box. Put the dashboard behind a reverse - proxy (oauth2-proxy, Cloudflare Access) if your team uses SSO; the - built-in auth then becomes a fallback for service accounts. - **Password rotation** has an endpoint but no UI yet — invoke `POST /api/auth/change-password` directly. diff --git a/docs/content/docs/guides/observability/dashboard-oauth.mdx b/docs/content/docs/guides/observability/dashboard-oauth.mdx new file mode 100644 index 0000000..a43310c --- /dev/null +++ b/docs/content/docs/guides/observability/dashboard-oauth.mdx @@ -0,0 +1,288 @@ +--- +title: Dashboard SSO (OAuth & OIDC) +description: "Sign in with Google, GitHub, or any OIDC provider. Per-domain / per-org allowlists, OAuth-only mode." +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + +The dashboard ships native sign-in for **Google**, **GitHub**, and any +**OIDC-compliant** provider (Okta, Auth0, Keycloak, Microsoft Entra, Dex, +…). Multiple OIDC providers can run side-by-side, each rendered as its +own button on the login screen. + +OAuth is **off by default**. Setting any provider's env vars turns it +on; password login remains enabled unless you opt out explicitly. + + + OAuth requires the `authlib` extra: + + ```bash + pip install 'taskito[oauth]' + # or with uv: + uv pip install 'taskito[oauth]' + ``` + + Skip this if you only use password login. + + +## How it works + +``` +Browser Dashboard Provider + │ │ │ + │ GET /login │ │ + ├─────────────────►│ │ + │ GET /api/auth/providers │ + ├─────────────────►│ │ + │ {providers, password_enabled} │ + │◄─────────────────┤ │ + │ click "Continue with Google" │ + │ GET /api/auth/oauth/start/google │ + ├─────────────────►│ mint state+nonce+PKCE + │ │ persist state row │ + │ 302 Location: │ + │◄─────────────────┤ │ + │ GET ─────────────────────► + │ user consents on Google │ + │◄──────────────────────────────────────┤ + │ GET /api/auth/oauth/callback/google?code=…&state=… + ├─────────────────►│ validate state │ + │ │ POST /token │ + │ ├───────────────────►│ + │ │ {id_token, access_token} + │ │◄───────────────────┤ + │ │ verify JWKS / nonce / aud / iss + │ │ check allowlist │ + │ │ get_or_create User │ + │ │ create Session │ + │ 302 Location: / + taskito_session cookie + taskito_csrf cookie + │◄─────────────────┤ │ +``` + +State is **single-use** and **time-bounded** (5-min default TTL). PKCE +S256, OIDC nonce, ID-token signature (via the provider's JWKS), +`iss` / `aud` / `exp` are all enforced server-side. + +## Quick start: Google login + +1. **Create an OAuth client.** Visit the + [Google Cloud Console → APIs & Services → Credentials](https://console.cloud.google.com/apis/credentials), + create an OAuth 2.0 Client ID of type *Web application*, and register + the callback URL: + + ``` + https://taskito.your-company.com/api/auth/oauth/callback/google + ``` + + (For local development, `http://localhost:8000/api/auth/oauth/callback/google` + works without HTTPS.) + +2. **Set env vars** before starting the dashboard: + + ```bash + export TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL=https://taskito.your-company.com + export TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID=...apps.googleusercontent.com + export TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET=... + # Restrict logins to your Google Workspace domain: + export TASKITO_DASHBOARD_OAUTH_GOOGLE_ALLOWED_DOMAINS=your-company.com + ``` + +3. **Start the dashboard.** The login screen now shows a "Continue with + Google" button above the password form. + +## GitHub login + +GitHub is OAuth2-only (no OIDC), so the dashboard hits `/user` and +`/user/emails` to derive an identity. Org membership is verified via +`/orgs/{org}/members/{login}`. + +1. Create a [GitHub OAuth App](https://github.com/settings/developers). + Set the *Authorization callback URL* to + `https://taskito.your-company.com/api/auth/oauth/callback/github`. + +2. Env vars: + + ```bash + export TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_ID=Iv1.xxxxx + export TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_SECRET=... + # Restrict logins to members of these GitHub orgs: + export TASKITO_DASHBOARD_OAUTH_GITHUB_ALLOWED_ORGS=your-org,partner-org + ``` + +When `ALLOWED_ORGS` is set the OAuth scope automatically expands to +include `read:org` so the membership endpoint returns reliable results +for private orgs. Users who consent without the additional scope are +rejected at the allowlist gate. + + + GitHub accounts that have no `verified=true` primary email + (returned by `GET /user/emails`) are always assigned the `viewer` + role, even if listed in `TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS`. This + prevents privilege escalation via spoofed email claims. + + +## Generic OIDC (Okta, Auth0, Keycloak, Microsoft, …) + +Generic OIDC providers are configured as **named slots**. Each slot has +its own callback URL, own user namespace, and own button on the login +screen. + +```bash +export TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL=https://taskito.your-company.com + +# List the slots first. +export TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS=okta,microsoft + +# Then per-slot config (slot name uppercase, separators normalised to _). +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_ID=... +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_SECRET=... +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_DISCOVERY_URL=https://acme.okta.com/.well-known/openid-configuration +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_LABEL="Acme SSO" +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_ALLOWED_DOMAINS=your-company.com + +export TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_CLIENT_ID=... +export TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_CLIENT_SECRET=... +export TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_DISCOVERY_URL=https://login.microsoftonline.com//v2.0/.well-known/openid-configuration +export TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_LABEL="Microsoft 365" +``` + +The callback URL for each slot is +`{REDIRECT_BASE_URL}/api/auth/oauth/callback/{slot}` — register that +exact URL with your IdP. + +Slot names must match `^[a-z][a-z0-9_-]{0,31}$` and must not collide +with `google` / `github` (the built-ins). The Taskito user generated +for an OIDC login is namespaced as `{slot}:{sub}`, so two different +Okta tenants stay distinct users even when subjects overlap. + +## Role assignment for OAuth users + +The first time someone signs in via OAuth, the dashboard decides their +role using this rule: + +1. **`TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS` match** — case-insensitive + match against a verified email → role `admin`. +2. **Empty user table fallback** — if no users (password or OAuth) exist + yet, the first OAuth user with a verified email becomes `admin`. +3. **Everyone else** → role `viewer`. + +```bash +export TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS=alice@your-company.com,bob@your-company.com +``` + +Once a user is created, their role is **not** re-evaluated on subsequent +logins (you can change it from the dashboard or via the API). Their +`email` and `display_name` are refreshed from each new login's claims. + +## OAuth-only mode + +To disable password login entirely: + +```bash +export TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=false +``` + +The dashboard refuses to start in OAuth-only mode if no provider is +configured (you'd have no way to log in). The login page hides the +username/password form and renders only provider buttons. + +## Allowlist semantics + +| Provider | Allowlist scope | Where it's checked | +|---|---|---| +| Google | `ALLOWED_DOMAINS` — the email domain (lowercased) must be in this list. Required: `email_verified=true`. | Server-side after JWKS verification of the ID token. | +| GitHub | `ALLOWED_ORGS` — user must be a member of at least one listed org. | `GET /orgs/{org}/members/{login}` returning 204. | +| Generic OIDC | `ALLOWED_DOMAINS` — same as Google. | Server-side after ID-token JWKS verification. | + +An **empty** allowlist means "any account from this provider is welcome" +— useful for personal projects but never appropriate for a production +deployment. Configure at least the admin-email list, and ideally a +domain/org allowlist too. + + + Allowlists are not editable from the dashboard UI. Changes require + restarting the server with new env values. This keeps the security + surface in one place (your deployment config) and avoids drift across + the operator's GitOps and the database. + + +## Security model + +| Control | Implementation | +|---|---| +| **PKCE** | S256 challenge derived from a 32-byte random verifier, per RFC 7636. Required by OAuth 2.1 and most providers in 2026. | +| **State** | 32-byte URL-safe random, stored server-side in `auth:oauth_state:` with a 5-min TTL. **Single-use** — deleted on first read. | +| **Nonce** | 16-byte random, embedded in the OIDC authorize request, verified against the ID-token `nonce` claim. Replay protection. | +| **ID-token signature** | Verified against the provider's JWKS (fetched from the discovery doc and cached per-provider). | +| **iss / aud / exp** | All validated; 60-second clock skew tolerance for `exp`. | +| **Open redirect** | The `next` query param is validated against `is_safe_redirect` — relative paths only, no scheme, no `//`. Falls back to `/`. | +| **HTTPS required** | `redirect_base_url` must be `https://` unless the host is `localhost` / `127.0.0.1`. Misconfiguration aborts startup. | +| **Provider tokens** | Never persisted. Only the verified identity flows into the Taskito session. | +| **Cross-provider linking** | Disabled by design. A given `(slot, subject)` always maps to one user. Two different providers with the same email = two different users. | + +## API surface + +| Method | Path | What it does | +|---|---|---| +| `GET` | `/api/auth/providers` | Public. Returns `{password_enabled, providers: [{slot, label, type}]}` for the login UI. | +| `GET` | `/api/auth/oauth/start/{slot}` | Public. Mints state, 302s to the provider's authorize URL. Accepts `?next=/path` (validated). | +| `GET` | `/api/auth/oauth/callback/{slot}` | Public. Validates state, exchanges code, enforces allowlist, creates/refreshes the user, sets cookies, 302s to `next`. | + +The callback uses the same `taskito_session` + `taskito_csrf` cookies +as password login — every other dashboard route works identically once +you're signed in. + +## Troubleshooting + +**"oauth_state_invalid"** — the state row expired (5-min window) or +already consumed. The user pressed back / refresh after the provider +redirect; have them start over. + +**"oauth_identity_failed: id_token issuer mismatch"** — the +`TASKITO_DASHBOARD_OAUTH_OIDC__DISCOVERY_URL` points to a +different issuer than what the IdP signed. Check the `issuer` field in +the discovery doc. + +**"oauth_allowlist_denied"** — the user authenticated successfully but +isn't in your allowlist. Either widen the allowlist or remove it. + +**Provider button doesn't appear** — `GET /api/auth/providers` returns +the list the UI renders. If the button is missing, check the server +logs for an env-var parse error at startup. The dashboard falls back to +password-only auth (logged at WARN) when env parsing fails. + +**"redirect_uri_mismatch" from the provider** — the callback URL you +registered with the provider doesn't match `{REDIRECT_BASE_URL}/api/auth/oauth/callback/{slot}`. +The trailing slash and the slot value must match exactly. + +## Env var reference + +```bash +# Required when any provider is configured. +TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL=https://taskito.company.com + +# Google. +TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID=... +TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET=... +TASKITO_DASHBOARD_OAUTH_GOOGLE_ALLOWED_DOMAINS=company.com,partner.com # optional + +# GitHub. +TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_ID=Iv1.xxxxx +TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_SECRET=... +TASKITO_DASHBOARD_OAUTH_GITHUB_ALLOWED_ORGS=org1,org2 # optional + +# Generic OIDC — list slots, then config each one. +TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS=okta,microsoft +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_ID=... +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_SECRET=... +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_DISCOVERY_URL=https://acme.okta.com/.well-known/openid-configuration +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_LABEL=Acme SSO # optional +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_ALLOWED_DOMAINS=company.com # optional + +# Role bootstrap. +TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS=alice@company.com,bob@company.com # optional + +# Disable password login (OAuth-only mode). Defaults to true. +TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=false # optional +``` diff --git a/docs/content/docs/guides/observability/meta.json b/docs/content/docs/guides/observability/meta.json index 145ef7f..bdaa19d 100644 --- a/docs/content/docs/guides/observability/meta.json +++ b/docs/content/docs/guides/observability/meta.json @@ -6,6 +6,7 @@ "notes", "dashboard", "dashboard-auth", + "dashboard-oauth", "task-overrides", "dashboard-api" ]