A Next.js web application for centrally managing an Anthias screen fleet. Upload a media file once and deploy it to all your screens simultaneously via the Anthias v2 API.
- Group broadcast — push an image or video to the entire fleet in one click
- Screen management — register any Anthias instance by IP/hostname, remove it if needed; the
SUPER_ADMINcan rename a screen and store the Basic-authentication login of devices that require one (password encrypted) - Asset deletion — delete an asset from the whole fleet, or remove every disabled (OFF) asset in one click, with a confirmation step
- Media sync — copy the media of an existing screen to a new or out-of-date one, with one click on the sync icon (automatic when a screen is added)
- Media library — view and edit already-deployed assets (name, duration, dates, enabled state)
- Authentication — login via Microsoft (Entra ID / Azure AD) or email + password
- Role-based access — four roles:
SUPER_ADMIN,ADMIN,USER,PENDING; new users wait for approval - User management — admin panel to approve new users and switch them between
USERandADMIN; theSUPER_ADMINcan also delete accounts - Built-in guide — a
/guidepage (button next to Refresh) explaining how to broadcast, manage media and use the dashboard, in French and English; screenshots open full screen with zoom and pan - Fixed header — the top bar stays visible while scrolling the dashboard and the guide
- Multilingual — French and English UI, switchable without a page reload (via
NEXT_LOCALEcookie)
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router, standalone output) |
| UI | Tailwind CSS v4, Lucide React |
| Auth | NextAuth v4 (Azure AD + Credentials providers) |
| i18n | next-intl v4 (cookie-based, no URL routing) |
| Database | SQLite via Prisma 7 |
| Runtime | Node.js 24 LTS |
| Deployment | Docker (multi-stage image) |
- Node.js 20+
- One or more Anthias instances reachable on the network
# 1. Install dependencies
npm install
# 2. Copy and fill environment variables
cp .env.example .env
# 3. Apply migrations (creates the SQLite database)
npx prisma migrate deploy
# 4. Generate the Prisma client
npx prisma generate
# 5. Create the SUPER_ADMIN account (the password is asked interactively)
npx tsx scripts/create-user.ts you@example.com "Your Name"
# 6. Start the development server
npm run devOpen http://localhost:3000.
docker compose up --build -dStarts two containers:
- nextjs-standalone — Next.js app (internal port 3001)
- anthias-nginx — Nginx reverse proxy, exposes HTTPS on port 3000
The SQLite database is persisted in a named Docker volume (sqlite_data → /app/data/prod.db).
Prisma migrations run automatically on container startup via docker/entrypoint.sh.
A self-signed TLS certificate is generated automatically on first start and stored in the nginx_certs volume. Set TLS_HOST in .env to the IP or hostname users type in the browser (it is written into the certificate's subjectAltName; changing it regenerates the certificate on the next start).
Public URL : https://<server-ip>:3000
DATABASE_URL: file:/app/data/prod.db (overridable via env)
The browser warns about the self-signed certificate. To remove the warning, import
server.crt(copy it withdocker cp anthias-nginx:/etc/nginx/certs/server.crt .) into the trusted certificates of your machines, or put your ownserver.crt/server.keyin thenginx_certsvolume.
docker exec -it nextjs-standalone-container npx tsx scripts/create-user.ts you@example.com "Your Name"The password is asked interactively (12 to 72 characters). For automation, set CREATE_USER_PASSWORD instead — never pass the password as an argument, it would end up in the shell history.
Because Azure requires HTTPS redirect URIs, the Nginx container handles TLS termination.
- Set in your
.env:NEXTAUTH_URL=https://<server-ip>:3000
- In Azure Portal → App registrations → Authentication → Redirect URIs, add:
https://<server-ip>:3000/api/auth/callback/azure-ad
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | Path to the SQLite database (file:./dev.db locally) |
NEXTAUTH_SECRET |
Yes | Random secret for JWT signing, at least 32 characters — generate with openssl rand -base64 32. The production server refuses to start if it is missing or still the example value |
NEXTAUTH_URL |
Yes | Public base URL of the app (e.g. https://192.168.1.72:3000) |
AZURE_AD_CLIENT_ID |
Optional | Azure App Registration client ID |
AZURE_AD_CLIENT_SECRET |
Optional | Azure App Registration client secret |
AZURE_AD_TENANT_ID |
Optional | Azure tenant GUID. Microsoft login is only enabled when the three Azure variables are set, and only accepts accounts of this tenant (common / organizations are rejected) |
CREDENTIALS_ENCRYPTION_KEY |
Optional | 32-byte base64 key used to encrypt the devices' passwords — generate with openssl rand -base64 32. Required to save a device login; keep a copy, stored passwords cannot be read without it |
ANTHIAS_USER / ANTHIAS_PASSWORD |
Optional | Fallback login used for devices that have none of their own |
TLS_HOST |
No | IP or hostname used in the nginx certificate (default: localhost) |
PORT |
No | Listening port (default: 3000) |
DATABASE_URL="file:./dev.db"
# Required, at least 32 characters. Generate with: openssl rand -base64 32
NEXTAUTH_SECRET=replace_with_openssl_rand_base64_32
NEXTAUTH_URL=http://localhost:3000
# Microsoft Entra ID (optional — leave empty to disable Microsoft login)
# AZURE_AD_TENANT_ID must be the tenant GUID (not "common" / "organizations")
AZURE_AD_CLIENT_ID=
AZURE_AD_CLIENT_SECRET=
AZURE_AD_TENANT_ID=
# Anthias devices with "Basic authentication" enabled: the SUPER_ADMIN stores each device's login in the app.
# Passwords are encrypted with this key (32 bytes, base64). Generate with: openssl rand -base64 32
# Do not lose it: stored passwords cannot be read without it.
CREDENTIALS_ENCRYPTION_KEY=
# Optional fallback used for devices that have no login of their own
ANTHIAS_USER=
ANTHIAS_PASSWORD=
# Hostname or IP used in the self-signed HTTPS certificate generated by the nginx container
TLS_HOST=localhost- Go to Azure Portal → App registrations → New registration
- Supported account types:
Accounts in this organizational directory only - Redirect URI:
Web→http://localhost:3000/api/auth/callback/azure-ad - Under Certificates & secrets, create a new client secret
- Copy Application (client) ID, Directory (tenant) ID and the secret value into
.env - For production, add your public URL as an additional redirect URI
Only accounts from the tenant set in AZURE_AD_TENANT_ID can sign in (the token's tid claim is checked). A Microsoft account whose email matches an existing local account is linked to it, except the SUPER_ADMIN, which can only sign in with its password. Provider tokens are not stored in the database.
The SUPER_ADMIN is created with the provided script (the password is asked interactively):
npx tsx scripts/create-user.ts <email> [display name]Password sign-in is limited to 5 failed attempts per email and 30 per client address every 15 minutes (plus a rate limit in nginx). Emails are case-insensitive. Sessions last 12 hours.
The user created this way is the SUPER_ADMIN. There can only be one: the script refuses to run if another SUPER_ADMIN already exists. Running it again with the same email resets the password and restores the SUPER_ADMIN role (useful to recover a locked-out account).
- Every API route checks the session (approved role, read from the database) — the proxy is not the only barrier. State-changing requests coming from another site are refused.
- Uploads are validated by content: only JPEG, PNG, GIF, WebP, MP4, WebM, MOV and Ogg are accepted (SVG and HTML are rejected), whatever type the browser announces. Media are served with
nosniffand a sandboxing CSP; unknown content is only offered as a download. - Screen addresses are validated: loopback, link-local (cloud metadata) and ambiguous spellings (
0177.0.0.1,2130706433) are refused, hostnames are resolved and checked. Private ranges (192.168.x.x…) stay allowed. - Security headers (CSP,
X-Frame-Options,nosniff,Referrer-Policy) are set on every response. HSTS is deliberately not enabled because the certificate is self-signed. - Anthias devices are reached over plain HTTP on the local network. Enable Basic authentication in their settings (see "Device authentication") and keep them on an isolated network segment: anyone who can reach a device directly bypasses the roles of this app.
- Upload size is checked before the body is read (100 MB), and transfers between devices are capped.
If Basic authentication is enabled in the settings of an Anthias device, the app must log in to its API.
- The SUPER_ADMIN sets the login of each device with the key icon next to it (username + password). Nobody else can set or change it.
- The password is encrypted (AES-256-GCM) with
CREDENTIALS_ENCRYPTION_KEYbefore being stored, and is never sent back to the browser: leaving the password empty keeps the stored one. Only the SUPER_ADMIN sees the username. - A device that answers but refuses the login is flagged with a warning icon for every user; sync reports it instead of failing silently.
- Devices without a login of their own use the optional
ANTHIAS_USER/ANTHIAS_PASSWORDfallback, if set. - A Raspberry Pi with Basic authentication takes over a second to answer (it checks the password on every request), so the online check waits up to 5 seconds per device (all devices are checked in parallel). An unreachable device can therefore delay the screen list by up to 5 seconds.
Keep a backup of CREDENTIALS_ENCRYPTION_KEY: if it is lost or changed, the stored passwords can no longer be decrypted and have to be entered again.
Adding a screen does not copy existing media by itself. The sync (automatic right after adding a screen, or via the sync icon on each screen) works as follows:
- The source is the first other screen that responds, ordered by creation.
- Only the media managed by the app are copied: the ones it broadcast (or synced). A media added directly on a device through its own Anthias interface is not known to the app, so it stays on that device and is never copied to the others.
- Media are matched by name: any managed media the target does not have is downloaded from the source and re-created on the target (same name, dates, order, enabled state).
- It is one-way and additive: nothing is deleted on the target, and a media already present (same name) is skipped, so running it again is safe.
- Videos are re-created with a duration of
0, as required by the Anthias API.
The managed names are stored in the ManagedAsset table. A media becomes managed when it is broadcast, stays managed when it is renamed from the app, and stops being managed when it is deleted from the app. Upgrade: the first time the library is loaded while the table is empty, the media already present on the reference device (the oldest screen) are adopted, so everything broadcast before this feature keeps syncing. Media added directly on a device before that first load are adopted too; add them afterwards to keep them local.
Asset IDs are generated by each Anthias device, so they differ between screens. Deleting an asset therefore works as follows:
- On the screen shown in the library (the oldest one), the asset is deleted by its ID.
- On every other screen, assets with the same name are deleted.
- "Delete OFF" targets the disabled assets of the library screen, then applies the same rule to the rest of the fleet.
- A screen that is unreachable is reported as a failure in the result toast; its assets are left untouched.
| Role | Access |
|---|---|
SUPER_ADMIN |
Everything an admin can do, plus promote / demote admins delete accounts, and rename each Anthias device and set its login. Only exists through scripts/create-user.ts and cannot be assigned from the UI or API |
ADMIN |
Screen management (add / remove), media, and user management: approve new users, switch non-admin users between USER and ADMIN. Cannot modify other admins |
USER |
Media only: broadcast, view, edit assets. Can see the screen fleet but cannot add or remove screens |
PENDING |
Blocked — sees a waiting page until an admin approves them |
New Microsoft sign-ins start as PENDING. On first login an admin clicks Approve at /admin/users (the user becomes USER). After that the role is a simple USER / ADMIN dropdown.
Nobody can change their own role, and nobody can modify the SUPER_ADMIN. Only the SUPER_ADMIN can delete an account (trash icon on /admin/users), never its own: the account and its linked Microsoft identity are removed, and the person can sign up again later as a new PENDING account.
The code lives in src/ and is organized by feature: each folder in src/features/ groups the UI, logic and types of one subject. src/app/ only contains routing (pages, layouts, API route handlers).
├── src/
│ ├── app/ # Routing only
│ │ ├── layout.tsx, globals.css
│ │ ├── page.tsx # Dashboard (renders useDashboard + dialogs)
│ │ ├── login/ pending/ # Login and waiting-for-approval pages
│ │ ├── admin/users/ # User management page (ADMIN only)
│ │ └── api/ # Route handlers (see "Internal API")
│ ├── features/
│ │ ├── auth/ # auth-options, require-user/admin, roles, rate-limit
│ │ ├── screens/ # ScreenManager, host validation, SSRF guard
│ │ ├── assets/ # AssetLibrary, BroadcastForm, preview/edit dialogs, mime detection, deletion
│ │ ├── users/ # UserManagement UI
│ │ ├── dashboard/ # use-dashboard hook (state and actions of the main page)
│ │ └── guide/ # Section structure and screenshot component of the /guide page
│ ├── components/ # Shared UI: Header, Toast, ConfirmDialog, LocaleSwitcher, Providers
│ ├── lib/ # Shared server helpers: prisma, anthias client, errors
│ ├── i18n/ messages/ # next-intl request config and fr/en translations
│ ├── types/ # NextAuth type augmentation
│ ├── proxy.ts # Auth + role guard for all routes (Next.js proxy, formerly middleware)
│ └── instrumentation.ts # Startup checks (NEXTAUTH_SECRET)
├── prisma/ # schema.prisma + migrations
├── scripts/ # create-user.ts (SUPER_ADMIN), purge-provider-tokens.ts
├── docker/
│ ├── entrypoint.sh # migrate deploy → node server.js
│ └── nginx/ # HTTPS reverse proxy (Dockerfile, entrypoint, nginx.conf)
├── Dockerfile # Multi-stage build on Node 24
├── compose.yml # nextjs-standalone + nginx services, SQLite and certificate volumes
└── next.config.ts, tsconfig.json, eslint.config.mjs, prisma.config.ts
The @/ import alias points to src/ (e.g. @/features/auth/roles).
The /guide page is built from the texts in src/messages/fr.json and en.json (namespace Guide) and the section list in src/features/guide/sections.ts. To add a section, add it to sections.ts and write its title, intro, steps (and optional tip, images) in both message files.
Screenshots are plain files in public/images/guide/ (PNG, about 1400 px wide, French interface, no real IP addresses or e-mails). A screenshot that does not exist yet is simply not displayed (in development, a dashed placeholder shows the expected file name). Each screenshot can be clicked to open it full screen: mouse wheel or +/- buttons to zoom, drag to move, double-click to toggle zoom, pinch on touch screens, Esc to close (component: src/features/guide/components/ZoomableImage.tsx).
| File | What it shows |
|---|---|
dashboard.png |
The whole dashboard: fleet on the left, broadcast form and library on the right |
login.png |
The login page |
pending.png |
The "access pending" page |
broadcast-form.png |
Broadcast form with an image selected (preview visible) |
broadcast-video.png |
Broadcast form with a video selected (Duration locked) |
library.png |
Library with active and OFF media (type icons, status, actions, "Delete OFF" button) |
asset-preview.png |
The full-size preview of a media |
asset-edit.png |
The "edit media" window |
delete-confirm.png |
The deletion confirmation window |
device-status.png |
Fleet list with the green, red and orange icons and the sync icon |
admin-users.png |
The user management page (administrators) |
| Method | Route | Auth | Description |
|---|---|---|---|
GET |
/api/screens |
USER+ | List all registered screens with online status |
POST |
/api/screens |
ADMIN | Add a screen { ip, label } |
PATCH |
/api/screens/[id] |
SUPER_ADMIN | Rename a screen { label } |
DELETE |
/api/screens/[id] |
ADMIN | Remove a screen |
PUT |
/api/screens/[id]/credentials |
SUPER_ADMIN | Set a device's login { username, password } (empty password keeps the stored one) |
DELETE |
/api/screens/[id]/credentials |
SUPER_ADMIN | Remove a device's login |
POST |
/api/screens/[id]/sync |
USER+ | Copy missing media from another online screen to this one |
GET |
/api/assets |
USER+ | Fetch asset list from the first available screen |
GET |
/api/assets/[id] |
USER+ | Retrieve asset content (base64) |
GET |
/api/assets/[id]/stream |
USER+ | Retrieve asset as binary (image or video) for the viewer |
DELETE |
/api/assets/[id] |
USER+ | Delete an asset on all screens |
DELETE |
/api/assets |
USER+ | Delete every disabled (OFF) asset on all screens |
PUT |
/api/assets/[id] |
USER+ | Update asset metadata on all screens |
POST |
/api/broadcast |
USER+ | Upload file + create asset on all screens |
GET |
/api/admin/users |
ADMIN | List all users |
PATCH |
/api/admin/users |
ADMIN | Update a user's role (admins can only modify non-admin users) |
DELETE |
/api/admin/users/[id] |
SUPER_ADMIN | Delete an account (not your own, not a SUPER_ADMIN) |
Screen calls proxy to the Anthias REST API v2 (
http://<ip>/api/v2).
- Create
src/messages/<code>.jsonwith all keys from an existing file - Add the locale code to
src/i18n/request.ts→localesarray - Add the locale code to
src/components/LocaleSwitcher.tsx→localesarray