feat: add auth.registration_enabled flag and admin ui user creation (#543)

Hey! @maxdorninger, could you please take a look on the other one ;) ?

Closes #145 ; seems like a must have thing

- Add `auth.registration_enabled` flag (default `false` — sounds safer
default in current setup)
- Password registration: `/auth/register` route will return 403 if
disabled
- OIDC registration: `UserManager.oauth_callback` is overridden to
reject user without match; so no auto-provision
- `/auth/metadata` exposes the flag to the frontend
- Frontend: signup link hidden on the login card; direct navigation to
`/login/signup` redirects to `/login` via a `+page.ts` load guard
  
  
<img width="463" height="461" alt="Screenshot 2026-05-16 at 20 52 11"
src="https://github.com/user-attachments/assets/1def5142-e930-4aa6-8771-cbff54250c1f"
/>
<img width="450" height="391" alt="Screenshot 2026-05-16 at 20 52 22"
src="https://github.com/user-attachments/assets/b4013964-cace-4aeb-a848-48ced86fcc5f"
/>

  
Also this means uses need to be created somehow -- so..
- Add POST `/users` - admin-protected endpoint to create a new users
(created user can be used with OIDC)
- Fronted: Add new user button and modal dialog  

<img width="800" height="379" alt="Screenshot 2026-05-17 at 11 32 06"
src="https://github.com/user-attachments/assets/048e6c43-a1c1-42ce-a19c-fd9d916a47d9"
/>
<img width="537" height="439" alt="Screenshot 2026-05-17 at 11 32 12"
src="https://github.com/user-attachments/assets/3f83c2c1-027b-4279-b6a1-bbc8da77efed"
/>

---

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Add config toggle to enable/disable user registration; when disabled
sign-up endpoints return 403 and OIDC won’t auto-create unknown users.
Added admin API to create users.

* **Frontend**
* Login UI hides signup link when registration is disabled; signup page
redirects to login. Admin users list gains “Add User” modal to create
users.

* **Documentation**
* Authentication docs and config examples updated to document the new
option.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/maxdorninger/MediaManager/pull/543?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Konstantin Chernyshev
2026-07-05 19:41:04 +02:00
committed by GitHub
parent e8f032f93a
commit bdb886d076
12 changed files with 317 additions and 17 deletions

View File

@@ -38,6 +38,7 @@ dbname = "MediaManager"
[auth]
email_password_resets = false # if true, you also need to set up SMTP (notifications.smtp_config)
registration_enabled = false # if false, the sign-up page is disabled and OIDC will not auto-create new users
token_secret = "CHANGE_ME_GENERATE_RANDOM_STRING" # generate a random string with "openssl rand -hex 32", e.g. here https://www.cryptool.org/en/cto/openssl/
session_lifetime = 86400 # this is how long you will be logged in after loggin in, in seconds

View File

@@ -19,10 +19,18 @@ All authentication settings are configured in the `[auth]` section of your `conf
A list of email addresses for administrator accounts. This is required.
* `email_password_resets`\
Enables password resets via email. Default is `false`.
* `registration_enabled`\
Allows new users to sign up for an account. Default is `false`.
!!! info
To use email password resets, you must also configure SMTP settings in the `[notifications.smtp_config]` section.
!!! info
When `registration_enabled` is `false`, the sign-up page is hidden, `/auth/register` returns 403, and OIDC rejects unknown users (existing users keep working). The bootstrap admin (see `admin_emails`) is unaffected.
!!! tip "Adding users when registration is disabled"
Administrators can add users at any time from the **Settings → Users** page via the "Add User" button. For OIDC-only users, leave the password field blank — a random password is generated and the user is matched to their OIDC account by email on first sign-in.
!!! info
When setting up MediaManager for the first time, you should add your email to `admin_emails` in the `[auth]` config section. MediaManager will then use this email instead of the default admin email. Your account will automatically be created as an admin account, allowing you to manage other users, media and settings.
@@ -70,6 +78,7 @@ token_secret = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6"
session_lifetime = 604800 # 1 week
admin_emails = ["admin@example.com", "manager@example.com"]
email_password_resets = true
registration_enabled = false
[auth.openid_connect]
enabled = true

View File

@@ -19,4 +19,5 @@ class AuthConfig(BaseSettings):
session_lifetime: int = 60 * 60 * 24
admin_emails: list[str] = []
email_password_resets: bool = False
registration_enabled: bool = False
openid_connect: OpenIdConfig = OpenIdConfig()

View File

@@ -1,18 +1,27 @@
import uuid
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, status
from fastapi import APIRouter, Depends, FastAPI, HTTPException, status
from fastapi_users import BaseUserManager, exceptions
from fastapi_users.router import get_oauth_router
from httpx_oauth.oauth2 import OAuth2
from sqlalchemy import select
from media_manager.auth.db import User
from media_manager.auth.schemas import AuthMetadata, UserRead
from media_manager.auth.schemas import (
AdminUserCreate,
AuthMetadata,
UserCreate,
UserRead,
)
from media_manager.auth.users import (
SECRET,
create_default_admin_user,
current_superuser,
fastapi_users,
get_user_manager,
openid_client,
openid_cookie_auth_backend,
)
@@ -59,7 +68,8 @@ def get_openid_router() -> APIRouter:
)
openid_config = MediaManagerConfig().auth.openid_connect
auth_config = MediaManagerConfig().auth
openid_config = auth_config.openid_connect
@users_router.get(
@@ -73,8 +83,45 @@ async def get_all_users(db: DbSessionDependency) -> list[UserRead]:
return [UserRead.model_validate(user) for user in result]
@users_router.post(
"/users/",
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(current_superuser)],
)
async def admin_create_user(
payload: AdminUserCreate,
user_manager: Annotated[
BaseUserManager[User, uuid.UUID], Depends(get_user_manager)
],
) -> UserRead:
password = payload.password or user_manager.password_helper.generate()
try:
user = await user_manager.create(
UserCreate(
email=payload.email,
password=password,
is_superuser=payload.is_superuser,
is_verified=payload.is_verified,
),
safe=False,
)
except exceptions.UserAlreadyExists as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A user with this email already exists.",
) from exc
except exceptions.InvalidPasswordException as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid password: {exc.reason}",
) from exc
return UserRead.model_validate(user)
@auth_metadata_router.get("/auth/metadata", status_code=status.HTTP_200_OK)
def get_auth_metadata() -> AuthMetadata:
if openid_config.enabled:
return AuthMetadata(oauth_providers=[openid_config.name])
return AuthMetadata(oauth_providers=[])
providers = [openid_config.name] if openid_config.enabled else []
return AuthMetadata(
oauth_providers=providers,
registration_enabled=auth_config.registration_enabled,
)

View File

@@ -1,7 +1,7 @@
import uuid
from fastapi_users import schemas
from pydantic import BaseModel
from pydantic import BaseModel, EmailStr
class UserRead(schemas.BaseUser[uuid.UUID]):
@@ -16,5 +16,13 @@ class UserUpdate(schemas.BaseUserUpdate):
pass
class AdminUserCreate(BaseModel):
email: EmailStr
password: str | None = None
is_superuser: bool = False
is_verified: bool = True
class AuthMetadata(BaseModel):
oauth_providers: list[str]
registration_enabled: bool

View File

@@ -4,9 +4,9 @@ import uuid
from collections.abc import AsyncGenerator
from typing import Any, override
from fastapi import Depends, Request
from fastapi import Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, Response
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, exceptions, models
from fastapi_users.authentication import (
AuthenticationBackend,
BearerTransport,
@@ -59,6 +59,43 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
updated_user = UserUpdate(is_verified=True)
await self.update(user=user, user_update=updated_user)
@override
async def oauth_callback(
self,
oauth_name: str,
access_token: str,
account_id: str,
account_email: str,
expires_at: int | None = None,
refresh_token: str | None = None,
request: Request | None = None,
*,
associate_by_email: bool = False,
is_verified_by_default: bool = False,
) -> User:
if not config.registration_enabled:
try:
await self.get_by_oauth_account(oauth_name, account_id)
except exceptions.UserNotExists:
try:
await self.get_by_email(account_email)
except exceptions.UserNotExists:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User registration is disabled.",
) from None
return await super().oauth_callback(
oauth_name,
access_token,
account_id,
account_email,
expires_at,
refresh_token,
request,
associate_by_email=associate_by_email,
is_verified_by_default=is_verified_by_default,
)
@override
async def on_after_register(
self, user: User, request: Request | None = None

View File

@@ -6,7 +6,15 @@ from contextlib import asynccontextmanager
import uvicorn
from asgi_correlation_id import CorrelationIdMiddleware
from fastapi import APIRouter, FastAPI, Request, Response
from fastapi import (
APIRouter,
Depends,
FastAPI,
HTTPException,
Request,
Response,
status,
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from psycopg.errors import UniqueViolation
@@ -153,10 +161,21 @@ api_app.include_router(
prefix="/auth/cookie",
tags=["auth"],
)
def reject_if_registration_disabled() -> None:
if not config.auth.registration_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User registration is disabled.",
)
api_app.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["auth"],
dependencies=[Depends(reject_if_registration_disabled)],
)
api_app.include_router(
fastapi_users.get_reset_password_router(), prefix="/auth", tags=["auth"]

View File

@@ -191,6 +191,23 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/users/': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Admin Create User */
post: operations['admin_create_user_api_v1_users__post'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/users/me': {
parameters: {
query?: never;
@@ -1075,10 +1092,32 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** AdminUserCreate */
AdminUserCreate: {
/**
* Email
* Format: email
*/
email: string;
/** Password */
password?: string | null;
/**
* Is Superuser
* @default false
*/
is_superuser?: boolean;
/**
* Is Verified
* @default true
*/
is_verified?: boolean;
};
/** AuthMetadata */
AuthMetadata: {
/** Oauth Providers */
oauth_providers: string[];
/** Registration Enabled */
registration_enabled: boolean;
};
/** BearerResponse */
BearerResponse: {
@@ -2155,6 +2194,39 @@ export interface operations {
};
};
};
admin_create_user_api_v1_users__post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
'application/json': components['schemas']['AdminUserCreate'];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['UserRead'];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
users_current_user_api_v1_users_me_get: {
parameters: {
query?: never;

View File

@@ -13,9 +13,11 @@
import { resolve } from '$app/paths';
let {
oauthProviderNames
oauthProviderNames,
registrationEnabled
}: {
oauthProviderNames: string[];
registrationEnabled: boolean;
} = $props();
let email = $state('');
@@ -125,10 +127,12 @@
>Login with {name}</Button
>
{/each}
<div class="mt-4 text-center text-sm">
<Button href={resolve('/login/signup/', {})} variant="link"
>Don't have an account? Sign up</Button
>
</div>
{#if registrationEnabled}
<div class="mt-4 text-center text-sm">
<Button href={resolve('/login/signup/', {})} variant="link"
>Don't have an account? Sign up</Button
>
</div>
{/if}
</Card.Content>
</Card.Root>

View File

@@ -7,6 +7,7 @@
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { invalidateAll } from '$app/navigation';
import client from '$lib/api';
@@ -20,6 +21,41 @@
let newEmail: string = $state('');
let dialogOpen = $state(false);
let deleteDialogOpen = $state(false);
let createDialogOpen = $state(false);
let createEmail: string = $state('');
let createPassword: string = $state('');
let createIsSuperuser: boolean = $state(false);
let isCreating: boolean = $state(false);
function resetCreateForm() {
createEmail = '';
createPassword = '';
createIsSuperuser = false;
}
async function createUser() {
if (isCreating) return;
isCreating = true;
try {
const { error } = await client.POST('/api/v1/users/', {
body: {
email: createEmail,
password: createPassword || null,
is_superuser: createIsSuperuser,
is_verified: true
}
});
if (error) {
toast.error(`Failed to create user: ${error.detail ?? error}`);
return;
}
toast.success(`User ${createEmail} created successfully.`);
createDialogOpen = false;
await invalidateAll();
} finally {
isCreating = false;
}
}
async function saveUser() {
if (!selectedUser) return;
@@ -73,6 +109,9 @@
}
</script>
<div class="mb-4 flex justify-end">
<Button onclick={() => (createDialogOpen = true)}>Add User</Button>
</div>
<Table.Root>
<Table.Caption>A list of all users.</Table.Caption>
<Table.Header>
@@ -248,3 +287,53 @@
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<Dialog.Root
open={createDialogOpen}
onOpenChange={(open) => {
createDialogOpen = open;
if (!open) resetCreateForm();
}}
>
<Dialog.Content class="w-full max-w-[500px] rounded-lg p-6 shadow-lg">
<Dialog.Header>
<Dialog.Title class="mb-1 text-xl font-semibold">Add user</Dialog.Title>
<Dialog.Description class="mb-4 text-sm">
Create a new user account. Leave password blank to auto-generate one (useful when the user
will sign in via OIDC).
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div>
<Label class="mb-1 block text-sm font-medium" for="create-email">Email</Label>
<Input
bind:value={createEmail}
class="w-full"
id="create-email"
placeholder="user@example.com"
required
type="email"
/>
</div>
<div>
<Label class="mb-1 block text-sm font-medium" for="create-password">Password</Label>
<Input
bind:value={createPassword}
class="w-full"
id="create-password"
placeholder="Leave blank for OIDC users"
type="password"
/>
</div>
<div class="flex items-center gap-2">
<Checkbox bind:checked={createIsSuperuser} id="create-superuser" />
<Label class="text-sm" for="create-superuser">Administrator</Label>
</div>
</div>
<div class="mt-8 flex justify-end gap-2">
<Button onclick={() => (createDialogOpen = false)} variant="outline">Cancel</Button>
<Button onclick={() => createUser()} disabled={!createEmail || isCreating}>
{isCreating ? 'Creating…' : 'Create'}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -14,5 +14,8 @@
</svelte:head>
<main>
<LoginCard oauthProviderNames={loginMetaData.oauth_providers} />
<LoginCard
oauthProviderNames={loginMetaData.oauth_providers}
registrationEnabled={loginMetaData.registration_enabled}
/>
</main>

View File

@@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import { resolve } from '$app/paths';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent }) => {
const { loginData } = await parent();
if (!loginData?.registration_enabled) {
redirect(307, resolve('/login', {}));
}
};