Admin Client Integration
Setting up an admin dashboard to call go-auth — cross-origin concerns, role checking, and the admin API surface.
Admin Client Integration
An admin dashboard is a separate frontend that manages users, sessions, and audit logs. It usually runs on a different host than the API, which brings cross-origin cookie and CSRF concerns that a same-origin user app doesn't have.
Fixed-role examples: app permissions disabled
The role checks and user role bodies on this page assume WithAppPermissions
is disabled. Enabled mode omits the account role field, allows selectively
delegated administrative actions, and uses appRoleId plus expected revisions
for assignment. Use the app permissions client guide
for /auth/access, permission-aware UI, and role assignment examples.
File convention
Recommended file names and locations in your project:
lib/
api/
http.ts ← the fetch wrapper (apiRequest, getCSRFToken, setUnauthorizedHandler)
auth.ts ← admin auth methods (login, verifyTwoFactor, resendTwoFactor, me)
admin.ts ← admin API methods (users, sessions, stats, invites, audit logs)
providers/
auth-provider.tsx ← admin AuthProvider with requireAdmin() + useAuth() hookKeep the admin client separate from the user-facing client — they share the same pattern but have different API surfaces and role requirements.
How it differs from a user-facing app
| Concern | User-facing app | Admin dashboard |
|---|---|---|
| Origin | Direct API or optional proxy | Direct API or optional proxy |
| CSRF token | JSON bootstrap; HttpOnly cookie | Same JSON bootstrap; HttpOnly cookie |
| Session | Any authenticated user | Must check role === "admin" |
| Context | Default app context | X-GoAuth-Session: admin on every request |
| Typical stack | Next.js + rewrites | Vite/React + VITE_API_URL |
Server-side requirements
One backend can serve both the app and this panel on sibling subdomains:
goauth.WithBrowser(goauth.BrowserConfig{
APIURL: "https://api.example.com",
AllowedOrigins: []string{"https://app.example.com", "https://admin.example.com"},
Cookies: goauth.CookieConfig{
Domain: "",
Path: "/",
SameSite: http.SameSiteLaxMode,
Secure: goauth.SecureAlways(),
},
})Place this inside NewConfig and import net/http. Cookie names can retain their defaults. Both frontends use JSON bootstrap; no readable cookie or shared domain is required. The panel selects the independent admin context on every request. Both frontends can call the same API host directly without replacing each other's login. See Deployment for HTTP development and different-site HTTPS deployments.
The fetch wrapper
Same pattern as the user-facing client, but with cross-origin notes:
// Set VITE_API_URL for direct API access, or leave it empty for a same-origin
// proxy. Both use HttpOnly cookies and the same JSON CSRF bootstrap contract.
const API_BASE = import.meta.env.VITE_API_URL ?? ""
export interface ApiError {
error: string
status?: number
message?: string
retryAfter?: number
}
let csrfToken: string | null = null
let csrfRequest: Promise<string> | null = null
async function getCSRFToken(force = false): Promise<string> {
if (force) csrfToken = null
if (csrfToken) return csrfToken
csrfRequest ??= (async () => {
const res = await fetch(`${API_BASE}/auth/csrf-token`, {
credentials: "include",
cache: "no-store",
headers: { "X-GoAuth-Session": "admin" },
})
if (!res.ok) throw new Error("Could not initialize CSRF protection")
const payload: { token?: unknown } = await res.json()
if (typeof payload.token !== "string" || !payload.token) {
throw new Error("CSRF bootstrap returned no token")
}
csrfToken = payload.token
return csrfToken
})().finally(() => { csrfRequest = null })
return csrfRequest
}
const csrfErrors = new Set([
"csrf_token_missing", "csrf_token_invalid", "csrf_token_mismatch",
])
let onUnauthorized: (() => void) | null = null
/** Lets the rest of the app react the moment any request comes back with a
* dead session, instead of only the one call that hit it. Wired up in
* AuthProvider. */
export function setUnauthorizedHandler(handler: (() => void) | null) {
onUnauthorized = handler
}
/** Best-effort human message from anything thrown by `apiRequest` or `fetch`. */
export function errorMessage(
err: unknown,
fallback = "Something went wrong"
): string {
if (typeof err === "string" && err) return err
if (err && typeof err === "object") {
const e = err as { message?: unknown; error?: unknown }
if (typeof e.message === "string" && e.message) return e.message
if (typeof e.error === "string" && e.error) return e.error
}
return fallback
}
export async function apiRequest<T = unknown>(
method: string,
path: string,
body?: unknown,
signal?: AbortSignal
): Promise<T> {
const headers: Record<string, string> = { "X-GoAuth-Session": "admin" }
if (body !== undefined) headers["Content-Type"] = "application/json"
const write = !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase())
if (write) headers["X-CSRF-Token"] = await getCSRFToken()
const send = () => fetch(`${API_BASE}${path}`, {
method,
credentials: "include",
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal,
})
let res = await send()
if (write && res.status === 403) {
const error = await res.clone().json().catch(() => ({})) as { error?: string }
// These reserved codes mean middleware rejected before executing the handler.
if (error.error && csrfErrors.has(error.error)) {
headers["X-CSRF-Token"] = await getCSRFToken(true)
res = await send()
}
}
const replacement = res.headers.get("X-CSRF-Token")
if (replacement) csrfToken = replacement
if (!res.ok) {
const err: ApiError = await res
.json()
.catch(() => ({ error: res.statusText }))
err.status = res.status
if (
res.status === 401 ||
(res.status === 403 && (err.error === "user_banned" || err.error === "two_factor_required" || (path.startsWith("/admin/") && err.error === "forbidden")))
) {
onUnauthorized?.()
}
if (res.status === 429) {
err.retryAfter = Number(res.headers.get("Retry-After")) || undefined
}
throw err
}
return res.status === 204 ? (undefined as T) : res.json()
}Authentication API methods
// lib/api/auth.ts
import { apiRequest } from "./http"
export interface AuthUser {
id: string
email: string
name: string
role: "user" | "admin" // this page assumes app permissions are disabled
}
export interface LoginResult {
user: AuthUser
step: "authenticated" | "verify_email" | "two_factor";
codeSent?: boolean
challengeId?: string
expiresAt?: string
}
export const authApi = {
login: (input: { email: string; password: string }) =>
apiRequest<LoginResult>("POST", "/auth/admin/login", input),
verifyTwoFactor: (challengeId: string, code: string) =>
apiRequest<{ user: AuthUser }>("POST", "/auth/2fa/verify", { challengeId, code }),
resendTwoFactor: (challengeId: string) =>
apiRequest<{ challengeId: string; expiresAt: string; codeSent: boolean }>(
"POST", "/auth/2fa/resend", { challengeId }
),
me: () => apiRequest<AuthUser>("GET", "/auth/me"),
logout: () => apiRequest("POST", "/auth/logout"),
}Admin API methods
// admin.ts
import { apiRequest } from "./http"
export const adminApi = {
login: (email: string, password: string) =>
apiRequest("POST", "/auth/admin/login", { email, password }),
// --- Users ---
listUsers: (params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/users?${new URLSearchParams(params as Record<string, string>)}`),
countUsers: (params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/users/count?${new URLSearchParams(params as Record<string, string>)}`),
getUser: (userId: string) =>
apiRequest("GET", `/admin/users/${userId}`),
createUser: (input: { email: string; password: string; name: string; role?: "user" | "admin" }) =>
apiRequest("POST", "/admin/users", input),
updateRole: (userId: string, role: "user" | "admin") =>
apiRequest("PATCH", `/admin/users/${userId}/role`, { role }),
ban: (userId: string) =>
apiRequest("PATCH", `/admin/users/${userId}/ban`),
unban: (userId: string) =>
apiRequest("PATCH", `/admin/users/${userId}/unban`),
deleteUser: (userId: string) =>
apiRequest("DELETE", `/admin/users/${userId}`),
// --- User sessions ---
listUserSessions: (userId: string, offset = 0, limit = 20) =>
apiRequest("GET", `/admin/users/${userId}/sessions?offset=${offset}&limit=${limit}`),
revokeUserSession: (userId: string, sessionId: string) =>
apiRequest("DELETE", `/admin/users/${userId}/sessions/${sessionId}`),
revokeUserSessions: (userId: string) =>
apiRequest("DELETE", `/admin/users/${userId}/sessions`),
// --- Stats ---
getStats: () =>
apiRequest("GET", "/admin/stats"),
getRegistrationTrend: (from: string, to: string) =>
apiRequest("GET", `/admin/stats/registrations?from=${from}&to=${to}`),
getLoginActivity: (from: string, to: string, userId?: string) =>
apiRequest("GET", `/admin/stats/logins?from=${from}&to=${to}${userId ? `&userId=${userId}` : ""}`),
// --- Audit logs ---
listAuditLogs: (params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/audit-logs?${new URLSearchParams(params as Record<string, string>)}`),
countAuditLogs: (params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/audit-logs/count?${new URLSearchParams(params as Record<string, string>)}`),
// --- Platform invites ---
createInvite: (email: string) =>
apiRequest("POST", "/admin/invites", { email }),
listInvites: (params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/invites?${new URLSearchParams(params as Record<string, string>)}`),
revokeInvite: (inviteId: string) =>
apiRequest("DELETE", `/admin/invites/${inviteId}`),
resendInvite: (inviteId: string) =>
apiRequest("POST", `/admin/invites/${inviteId}/resend`),
deleteInvite: (inviteId: string) =>
apiRequest("DELETE", `/admin/invites/${inviteId}/hard`),
}This context example assumes verified administrators. A backend can also
return step: "verify_email"; the complete verification and continuation
contract is documented in Authentication outcomes.
React context with role checking
The admin AuthProvider adds a critical check: a valid session is not necessarily an admin session.
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react"
import { authApi, type AuthUser } from "../lib/api/auth"
import { setUnauthorizedHandler } from "../lib/api/http"
interface TwoFactorChallenge {
challengeId: string
expiresAt: string
codeSent: boolean
}
interface AuthContextValue {
user: AuthUser | null
loading: boolean
challenge: TwoFactorChallenge | null
login: (input: { email: string; password: string }) => Promise<void>
verifyTwoFactor: (code: string) => Promise<void>
resendTwoFactor: () => Promise<void>
logout: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
/**
* A valid session is not necessarily an admin session. Cookies are scoped to
* the host and ignore the port, so a regular user signed into the app on
* another localhost port arrives here authenticated with no admin rights.
* Without this the panel renders its whole shell and then 403s on every
* /admin/* call it makes.
*/
function requireAdmin(user: AuthUser): AuthUser | null {
return user.role === "admin" ? user : null
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null)
const [loading, setLoading] = useState(true)
const [challenge, setChallenge] = useState<TwoFactorChallenge | null>(null)
const refresh = useCallback(async () => {
try {
setUser(requireAdmin(await authApi.me()))
} catch {
setUser(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
setUnauthorizedHandler(() => setUser(null))
refresh()
}, [refresh])
const login = useCallback(async (input: { email: string; password: string }) => {
setChallenge(null)
const result = await authApi.login(input)
if (result.step === "verify_email") {
throw new Error("Verify your email before admin login; follow the authentication-outcomes verification flow.")
}
if (result.step === "two_factor") {
setChallenge({ challengeId: result.challengeId!, expiresAt: result.expiresAt!, codeSent: result.codeSent ?? false })
return
}
await refresh()
}, [refresh])
const verifyTwoFactor = useCallback(async (code: string) => {
if (!challenge) throw new Error("No active 2FA challenge")
const result = await authApi.verifyTwoFactor(challenge.challengeId, code)
setChallenge(null)
setUser(requireAdmin(result.user))
}, [challenge])
const resendTwoFactor = useCallback(async () => {
if (!challenge) throw new Error("No active 2FA challenge")
const result = await authApi.resendTwoFactor(challenge.challengeId)
setChallenge(result)
}, [challenge])
const logout = useCallback(async () => {
await authApi.logout()
setUser(null)
setChallenge(null)
if (typeof window !== "undefined") window.location.href = "/login"
}, [])
return (
<AuthContext.Provider value={{ user, loading, challenge, login, verifyTwoFactor, resendTwoFactor, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error("useAuth must be used within an AuthProvider")
return ctx
}Why requireAdmin?
The panel's selector keeps an app login from being mistaken for a panel login. An existing admin-context session can still lose access when its account role, grants, ban status, or session assurance changes. requireAdmin helps the UI return to sign-in when current access no longer permits the panel; the backend remains responsible for every authorization decision.
With app permissions disabled, /admin/* checks the admin role and required session assurance server-side. The browser role check improves navigation only; it cannot authorize an operation. Enabled mode uses exact library permissions or protected-admin policy, rather than a fixed account role.
// Check once, on session load:
const user = requireAdmin(await authApi.me())
setUser(user) // null if not admin → shows login screenThis turns a dashboard full of failed requests into a clean login screen.
Admin login flow
Admin login always requires 2FA (unless DisableAdminTwoFactor is set):
const login = useCallback(async (input: { email: string; password: string }) => {
setChallenge(null)
const result = await authApi.login(input)
if (result.step === "two_factor") {
setChallenge({ challengeId: result.challengeId!, expiresAt: result.expiresAt!, codeSent: result.codeSent ?? false }) // show emailed-code input
return
}
await refresh() // only if 2FA is disabled
}, [refresh])
const verifyTwoFactor = useCallback(async (code: string) => {
if (!challenge) throw new Error("No active 2FA challenge")
const result = await authApi.verifyTwoFactor(challenge.challengeId, code)
setChallenge(null)
setUser(requireAdmin(result.user))
}, [challenge])Vite environment variables
# .env.local
VITE_API_URL=https://api.myapp.comVite exposes VITE_ variables through import.meta.env automatically; no custom define entry is needed. Build the panel and deploy dist/ to the admin subdomain, with SPA fallback to index.html.
Build time, not runtime
VITE_API_URL is baked into the bundle at build time. A different API URL means a different build. For runtime configuration, use a small config endpoint or environment injection.
Organizations admin
Admin endpoints for organization management:
export const orgAdminApi = {
listOrgs: (params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/orgs?${new URLSearchParams(params as Record<string, string>)}`),
countOrgs: (params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/orgs/count?${new URLSearchParams(params as Record<string, string>)}`),
getOrg: (orgId: string) =>
apiRequest("GET", `/admin/orgs/${orgId}`),
listMembers: (orgId: string) =>
apiRequest("GET", `/admin/orgs/${orgId}/members`),
addMember: (orgId: string, userId: string, role: string) =>
apiRequest("POST", `/admin/orgs/${orgId}/members`, { userId, role }),
removeMember: (orgId: string, userId: string) =>
apiRequest("DELETE", `/admin/orgs/${orgId}/members/${userId}`),
deleteOrg: (orgId: string) =>
apiRequest("DELETE", `/admin/orgs/${orgId}`),
}Complete admin panel coming soon
A full-featured admin dashboard built with Vite is on the way — user management, session revocation, audit logs, organization admin, and role-based access, all wired up and ready to fork.
Next
- Client Integration — the base fetch wrapper and React context pattern
- Deployment — cross-origin topology requirements
- Admin Guide — full admin API reference
- Audit Logs — event types and query params