5 Commits

Author SHA1 Message Date
jamie 5b606404fc Merge pull request 'V2.1.0' (#57) from v2.1.0 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ipam/pulls/57
2026-07-06 18:37:12 +01:00
jamie 8f135c8dd9 fix: 🐛 update SSO callback logic to handle error parameters and remove redundant 2FA checks
Release / SonarQube (pull_request) Successful in 26s
Release / Build & Release (pull_request) Successful in 29s
2026-07-06 18:36:51 +01:00
jamie d476901a53 feat: add SSO authentication support, configurable brand accent colors, and update UI theme 2026-07-06 18:20:01 +01:00
jamie 1346e9e5f5 docs: 📝 correct url 2026-05-30 21:43:29 +01:00
jamie af4f16aa59 docs: 📝 update readme 2026-05-30 21:42:28 +01:00
18 changed files with 459 additions and 54 deletions
+1
View File
@@ -2,3 +2,4 @@ __pycache__
.env
frontend/node_modules/
static/dist/
venv/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 JDB-NET
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+62 -21
View File
@@ -1,28 +1,47 @@
<div align="center">
<img src="https://assets.jdbnet.co.uk/projects/ipam.png" alt="IPAM" width="200" />
# IP Address Management
<h1>JDB-NET IPAM</h1>
<p>Open source IP address management for homelabs, small businesses, and IT teams.</p>
<p>
<a href="https://github.com/jdbnet/ipam/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/jdbnet/ipam" alt="License" />
</a>
<a href="https://cr.jdbnet.co.uk">
<img src="https://img.shields.io/badge/container-cr.jdbnet.co.uk-blue" alt="Container" />
</a>
</p>
<p>
<a href="https://www.jdbnet.co.uk/product/ipam"><strong>☁️ Managed hosting from £8/month →</strong></a>
</p>
</div>
A Flask-based web application for IP Address Management (IPAM). Manage subnets, IP addresses, devices, DHCP pools, and rack infrastructure through a Vue 3 web interface and a JSON REST API.
---
## Features
- **Subnet management** - CIDR subnets with automatic IP generation
- **IP assignment** - Assign addresses to devices with hostname tracking
- **Device management** - Names, descriptions, tags, and custom fields
- **DHCP pools** — Configure ranges and excluded IPs per subnet
- **Rack management** - U positions with front/back layout
- **Site organisation** - Group subnets and devices by location
- **Audit logging** - Filterable change history with CSV export
- **Role-based access control** - Granular permissions and custom roles
- **REST API v2** - Session cookies for the browser, API keys for automation
## Screenshot
Manage subnets, IP assignments, DHCP pools, devices, and rack layout from
a single web interface. Built with Flask and Vue 3, deployable with a single
Docker Compose file.
![IPAM Dashboard](img/screenshot.png)
## Docker Compose
## Features
- **Subnet management** - CIDR subnets (/24/32) with automatic IP generation
- **IP assignment** - Assign addresses to devices with hostname tracking and assignment history
- **DHCP pools** - Configure ranges and excluded IPs per subnet; pool addresses are kept out of manual assignment
- **Device management** - Names, descriptions, tags, custom fields, and bulk creation
- **Rack layout** - U positions with front/back face placement and non-networked entries
- **Site organisation** - Group subnets and devices by location for multi-site networks
- **Global search** - Press `/` to search subnets, IPs, devices, and racks from anywhere
- **Audit logging** - Filterable change history with CSV export
- **Role-based access control** - Granular permissions, custom roles, and enforced 2FA per role
- **REST API v2** - Full JSON API with session cookie and API key authentication
- **Custom fields** - Extend devices and subnets with admin-defined fields, no schema changes required
- **Organisation branding** - Set your name and logo from Settings or environment variables
## Quick start
```yaml
services:
@@ -33,11 +52,33 @@ services:
ports:
- "5000:5000"
environment:
- MYSQL_HOST=10.10.2.27
- MYSQL_HOST=your_db_host
- MYSQL_USER=ipam
- MYSQL_PASSWORD=your_password
- MYSQL_DATABASE=ipam
- SECRET_KEY=your_secret_key
- NAME=Your Organisation
- LOGO_PNG=https://example.com/logo.png
- SECRET_KEY=your_secret_key # generate with: openssl rand -hex 32
```
A MySQL or MariaDB database is required. The schema is created automatically
on first run. Log in with `admin@example.com` / `password` and change the password immediately.
## Environment variables
| Variable | Required | Description |
|----------|----------|-------------|
| `MYSQL_HOST` | Yes | Database host |
| `MYSQL_USER` | Yes | Database user |
| `MYSQL_PASSWORD` | Yes | Database password |
| `MYSQL_DATABASE` | Yes | Database name |
| `SECRET_KEY` | Yes | Flask secret key - use a long random string |
## Managed hosting
Don't want to run it yourself? JDB-NET offers fully managed hosting from
**£8/month** - provisioned in under 10 minutes, no maintenance required.
[→ jdbnet.co.uk/products/ipam](https://www.jdbnet.co.uk/product/ipam)
## License
[MIT](LICENSE)
+179 -2
View File
@@ -17,6 +17,11 @@ from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address
import pyotp
import qrcode
import mysql.connector
import requests
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore")
from authlib.jose import jwt, JsonWebKey
from dotenv import load_dotenv
from flask import (
Flask, session, request, abort, jsonify, redirect,
@@ -1219,6 +1224,173 @@ def group_devices_by_site(devices):
# ── Auth & account (v2) ───────────────────────────────────────────────────────
def get_sso_settings():
import os
return {
"enabled": os.environ.get("SSO_ENABLED", "false").lower() == "true",
"issuer": os.environ.get("SSO_ISSUER_URL", ""),
"client_id": os.environ.get("SSO_CLIENT_ID", ""),
"client_secret": os.environ.get("SSO_CLIENT_SECRET", ""),
}
def get_public_base_url():
from flask import request
return request.host_url.rstrip("/")
@app.route("/api/v2/auth/capabilities", methods=["GET"])
def api_auth_capabilities():
sso = get_sso_settings()
return jsonify({
"sso_enabled": sso["enabled"],
})
@app.route("/api/v2/auth/sso/login", methods=["GET"])
def api_sso_login():
import secrets, base64, hashlib, requests, logging
sso = get_sso_settings()
if not sso["enabled"] or not sso["issuer"] or not sso["client_id"]:
return jsonify({"error": "SSO is not configured"}), 400
try:
resp = requests.get(f"{sso['issuer'].rstrip('/')}/.well-known/openid-configuration", timeout=10)
resp.raise_for_status()
oidc_config = resp.json()
except Exception as e:
logging.error(f"Failed to fetch OIDC configuration: {e}")
return jsonify({"error": "Failed to fetch OIDC configuration"}), 500
state = secrets.token_urlsafe(32)
code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode("ascii")).digest()
).decode("ascii").rstrip("=")
session["sso_state"] = state
session["sso_code_verifier"] = code_verifier
session.modified = True
redirect_uri = get_public_base_url() + "/sso/callback"
auth_endpoint = oidc_config["authorization_endpoint"]
url = (
f"{auth_endpoint}?"
f"response_type=code&"
f"client_id={sso['client_id']}&"
f"redirect_uri={redirect_uri}&"
f"scope=openid email profile&"
f"state={state}&"
f"code_challenge={code_challenge}&"
f"code_challenge_method=S256"
)
return jsonify({"url": url})
@app.route("/api/v2/auth/sso/callback", methods=["POST"])
def api_sso_callback():
import requests, logging
from flask import current_app
data = request.get_json(silent=True) or {}
code = data.get("code")
state = data.get("state")
if not code or not state:
return jsonify({"error": "Missing code or state"}), 400
if state != session.get("sso_state"):
return jsonify({"error": "Invalid state"}), 400
code_verifier = session.get("sso_code_verifier")
if not code_verifier:
return jsonify({"error": "Missing code verifier in session"}), 400
sso = get_sso_settings()
if not sso["enabled"]:
return jsonify({"error": "SSO is not enabled"}), 400
try:
resp = requests.get(f"{sso['issuer'].rstrip('/')}/.well-known/openid-configuration", timeout=10)
resp.raise_for_status()
oidc_config = resp.json()
except Exception as e:
logging.error(f"Failed to fetch OIDC configuration: {e}")
return jsonify({"error": "Failed to fetch OIDC configuration"}), 500
token_endpoint = oidc_config["token_endpoint"]
redirect_uri = get_public_base_url() + "/sso/callback"
try:
token_resp = requests.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"client_id": sso["client_id"],
"client_secret": sso["client_secret"],
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
},
timeout=10,
)
token_resp.raise_for_status()
token_data = token_resp.json()
except Exception as e:
logging.error(f"SSO token exchange failed: {e}")
return jsonify({"error": "SSO token exchange failed"}), 401
id_token = token_data.get("id_token")
if not id_token:
return jsonify({"error": "No ID token returned"}), 400
try:
jwks_uri = oidc_config.get("jwks_uri")
if not jwks_uri:
raise ValueError("OIDC configuration is missing jwks_uri")
jwks_resp = requests.get(jwks_uri, timeout=10)
jwks_resp.raise_for_status()
jwks_data = jwks_resp.json()
keys = JsonWebKey.import_key_set(jwks_data)
claims_options = {
"iss": {"essential": True, "value": sso["issuer"]},
"aud": {"essential": True, "value": sso["client_id"]},
"exp": {"essential": True}
}
claims = jwt.decode(id_token, keys, claims_options=claims_options)
claims.validate()
except Exception as e:
logging.error(f"Failed to decode or validate ID token: {e}")
return jsonify({"error": "Invalid ID token"}), 400
email = claims.get("email")
if not email:
return jsonify({"error": "No email provided in ID token"}), 400
email = email.strip().lower()
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"SELECT id, name, email, password as password_hash, role_id, totp_secret, totp_enabled, two_fa_setup_complete "
"FROM User WHERE email = %s",
(email,),
)
user = cursor.fetchone()
if not user:
add_audit_log(None, "sso_failed", f"SSO login failed: User not found ({email})", conn=conn)
return jsonify({"error": "Account not found."}), 403
establish_user_session(user['id'], conn)
add_audit_log(user['id'], "login", "Successful SSO login", conn=conn)
return jsonify({"ok": True})
@app.route('/api/v2/auth/login', methods=['POST'])
def api_auth_login():
data = json_body()
@@ -1458,7 +1630,9 @@ def api_info():
'id': get_current_user_id(),
'name': current_user()['name'],
'email': current_user()['email']
}
} if current_user() else None,
'permissions': list(current_user()['permissions']) if current_user() else [],
'org': org_branding(current_app),
})
# Devices API
@@ -3058,6 +3232,7 @@ def api_get_settings():
return jsonify({
'org_name': app.config['NAME'],
'org_logo': app.config['LOGO_PNG'],
'accent_color': app.config.get('ACCENT_COLOR') or '#1ebe8a',
})
@@ -3067,7 +3242,8 @@ def api_update_settings():
data = json_body()
name = (data.get('org_name') or '').strip()
logo = (data.get('org_logo') or '').strip()
save_org_settings(current_app, name, logo)
accent_color = (data.get('accent_color') or '').strip()
save_org_settings(current_app, name, logo, accent_color)
with get_db_connection(current_app) as conn:
add_audit_log(
get_current_user_id(),
@@ -3078,6 +3254,7 @@ def api_update_settings():
return jsonify({
'org_name': name,
'org_logo': logo,
'accent_color': accent_color or '#1ebe8a',
'org': org_branding(),
})
+11 -1
View File
@@ -609,8 +609,10 @@ def run_v2_migrations(cursor, conn):
DEFAULT_ORG_NAME = 'JDB-NET'
DEFAULT_ORG_LOGO = 'https://assets.jdbnet.co.uk/projects/ipam.png'
DEFAULT_ACCENT_COLOR = '#1ebe8a'
ORG_NAME_KEY = 'org_name'
ORG_LOGO_KEY = 'org_logo'
ACCENT_COLOR_KEY = 'accent_color'
def get_setting(cursor, key):
@@ -639,6 +641,7 @@ def load_org_settings(app):
try:
name = get_setting(cursor, ORG_NAME_KEY).strip()
logo = get_setting(cursor, ORG_LOGO_KEY).strip()
accent = get_setting(cursor, ACCENT_COLOR_KEY).strip()
if not name and env_name:
name = env_name
@@ -652,24 +655,29 @@ def load_org_settings(app):
app.config['NAME'] = name
app.config['LOGO_PNG'] = logo
app.config['ACCENT_COLOR'] = accent
conn.commit()
finally:
cursor.close()
conn.close()
def save_org_settings(app, name, logo):
def save_org_settings(app, name, logo, accent_color=None):
conn = get_db_connection(app)
cursor = conn.cursor()
try:
set_setting(cursor, ORG_NAME_KEY, name)
set_setting(cursor, ORG_LOGO_KEY, logo)
if accent_color is not None:
set_setting(cursor, ACCENT_COLOR_KEY, accent_color)
conn.commit()
finally:
cursor.close()
conn.close()
app.config['NAME'] = name
app.config['LOGO_PNG'] = logo
if accent_color is not None:
app.config['ACCENT_COLOR'] = accent_color
def org_branding(app=None):
@@ -678,7 +686,9 @@ def org_branding(app=None):
app = current_app
name = (app.config.get('NAME') or '').strip()
logo = (app.config.get('LOGO_PNG') or '').strip()
accent = (app.config.get('ACCENT_COLOR') or '').strip()
return {
'name': name or DEFAULT_ORG_NAME,
'logo': logo or DEFAULT_ORG_LOGO,
'accent_color': accent or DEFAULT_ACCENT_COLOR,
}
+27
View File
@@ -1,5 +1,32 @@
<script setup lang="ts">
import { watch, onMounted } from "vue";
import { RouterView } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
function hexToRgb(hex: string) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim());
return result ? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}` : null;
}
function applyAccentColor() {
const color = auth.org?.accent_color;
if (color) {
const rgb = color.startsWith('#') ? hexToRgb(color) : color;
if (rgb) {
document.documentElement.style.setProperty("--accent", rgb);
document.documentElement.style.setProperty("--accent-muted", rgb);
}
}
}
onMounted(() => {
applyAccentColor();
});
watch(() => auth.org?.accent_color, applyAccentColor);
</script>
<template>
<RouterView />
+19 -4
View File
@@ -23,7 +23,7 @@ function fetchApi(path: string, init?: RequestInit) {
export interface MeResponse {
logged_in: boolean;
app_version?: string;
org?: { name: string; logo: string };
org?: { name: string; logo: string; accent_color?: string };
user?: { id: number; name: string; email: string };
permissions?: string[];
}
@@ -146,6 +146,21 @@ export interface AuditParams {
}
export const api = {
async capabilities() {
return handle<{ sso_enabled: boolean }>(await fetchApi("/api/v2/auth/capabilities"));
},
async startSsoLogin() {
return handle<{ url: string }>(await fetchApi("/api/v2/auth/sso/login"));
},
async ssoCallback(code: string, state: string) {
return handle<{ ok?: boolean; requires_2fa?: boolean; requires_setup?: boolean }>(
await fetchApi("/api/v2/auth/sso/callback", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ code, state }),
})
);
},
async me(): Promise<MeResponse> {
return handle(await fetchApi("/api/v2/auth/me"));
},
@@ -400,10 +415,10 @@ export const api = {
return d.items;
},
async settings() {
return handle<{ org_name: string; org_logo: string }>(await fetchApi("/api/v2/settings"));
return handle<{ org_name: string; org_logo: string; accent_color?: string }>(await fetchApi("/api/v2/settings"));
},
async updateSettings(body: { org_name: string; org_logo: string }) {
return handle<{ org_name: string; org_logo: string; org?: { name: string; logo: string } }>(
async updateSettings(body: { org_name: string; org_logo: string; accent_color?: string }) {
return handle<{ org_name: string; org_logo: string; accent_color?: string; org?: { name: string; logo: string; accent_color?: string } }>(
await fetchApi("/api/v2/settings", { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }),
);
},
+1
View File
@@ -7,6 +7,7 @@ const router = createRouter({
{ path: "/login", name: "login", component: () => import("@/views/LoginView.vue"), meta: { public: true } },
{ path: "/verify-2fa", name: "verify-2fa", component: () => import("@/views/Verify2faView.vue"), meta: { public: true } },
{ path: "/setup-2fa", name: "setup-2fa", component: () => import("@/views/Setup2faView.vue"), meta: { public: true } },
{ path: "/sso/callback", name: "sso-callback", component: () => import("@/views/SSOCallbackView.vue"), meta: { public: true } },
{
path: "/",
component: () => import("@/components/AppLayout.vue"),
+1 -1
View File
@@ -7,7 +7,7 @@ export const useAuthStore = defineStore("auth", {
loggedIn: false,
user: null as MeResponse["user"] | null,
permissions: [] as string[],
org: { name: "IPAM", logo: "" },
org: { name: "IPAM", logo: "", accent_color: "#1ebe8a" },
version: "unknown",
}),
getters: {
+9 -9
View File
@@ -4,19 +4,19 @@
@layer base {
:root {
--surface: 248 250 252;
--surface: 240 246 252;
--surface-raised: 255 255 255;
--surface-overlay: 241 245 249;
--accent: 6 182 212;
--accent-muted: 8 145 178;
--surface-overlay: 230 237 243;
--accent: 30 190 138;
--accent-muted: 24 152 110;
}
@media (prefers-color-scheme: dark) {
:root {
--surface: 15 20 25;
--surface-raised: 21 28 36;
--surface-overlay: 26 35 46;
--accent: 34 211 238;
--accent-muted: 6 182 212;
--surface: 13 17 23;
--surface-raised: 22 27 34;
--surface-overlay: 33 38 45;
--accent: 30 190 138;
--accent-muted: 24 152 110;
}
}
}
+2 -2
View File
@@ -74,7 +74,7 @@ async function regenCodes() {
</ul>
</div>
<div v-if="newBackupCodes.length">
<p class="text-sm font-medium text-accent">New backup codes save these now:</p>
<p class="text-sm font-medium text-accent">New backup codes - save these now:</p>
<ul class="mt-2 rounded-lg bg-surface-overlay p-3 font-mono text-sm">
<li v-for="c in newBackupCodes" :key="c">{{ c }}</li>
</ul>
@@ -87,7 +87,7 @@ async function regenCodes() {
class="text-sm text-red-500 hover:underline"
@click="disable2fa"
>Disable 2FA</button>
<p v-else class="text-sm text-slate-500">Your role requires 2FA it cannot be disabled.</p>
<p v-else class="text-sm text-slate-500">Your role requires 2FA - it cannot be disabled.</p>
</div>
</template>
<template v-else>
+2 -2
View File
@@ -36,7 +36,7 @@ const activity = ref<ActivityPoint[]>([]);
const donutStyle = computed(() => {
const pct = stats.value?.utilization_percent ?? 0;
return { background: `conic-gradient(rgb(6 182 212) ${pct}%, rgb(var(--surface-overlay)) ${pct}%)` };
return { background: `conic-gradient(rgb(var(--accent)) ${pct}%, rgb(var(--surface-overlay)) ${pct}%)` };
});
const maxActivity = computed(() => Math.max(1, ...activity.value.map((a) => a.count)));
@@ -128,7 +128,7 @@ function formatHour(h: number) {
</div>
<div class="card">
<h2 class="font-semibold">Activity last 24 hours</h2>
<h2 class="font-semibold">Activity - last 24 hours</h2>
<p class="mt-1 text-xs text-slate-500">Audit log entries by hour</p>
<div class="mt-4 flex h-40 items-end gap-0.5">
<div
+31 -2
View File
@@ -1,16 +1,40 @@
<script setup lang="ts">
import { ref } from "vue";
import { ref, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import { api } from "@/api";
const email = ref("");
const password = ref("");
const err = ref("");
const busy = ref(false);
const ssoEnabled = ref(false);
const ssoLoading = ref(false);
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
onMounted(async () => {
try {
const caps = await api.capabilities();
ssoEnabled.value = caps.sso_enabled;
} catch (e) {
// ignore
}
});
async function startSsoLogin() {
err.value = "";
ssoLoading.value = true;
try {
const { url } = await api.startSsoLogin();
window.location.href = url;
} catch (e) {
err.value = e instanceof Error ? e.message : "SSO initiation failed";
ssoLoading.value = false;
}
}
async function submit() {
err.value = "";
busy.value = true;
@@ -48,7 +72,12 @@ async function submit() {
<input v-model="password" type="password" class="input-field" required autocomplete="current-password" />
</div>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<button type="submit" class="btn-primary w-full" :disabled="busy">{{ busy ? "Signing in…" : "Sign in" }}</button>
<button type="submit" class="btn-primary w-full" :disabled="busy || ssoLoading">{{ busy ? "Signing in…" : "Sign in" }}</button>
<div v-if="ssoEnabled" class="pt-4 border-t border-slate-200 dark:border-slate-800">
<button type="button" class="btn-secondary w-full" :disabled="ssoLoading || busy" @click="startSsoLogin">
{{ ssoLoading ? 'Redirecting…' : 'Sign in with Single Sign-On' }}
</button>
</div>
</form>
</div>
</div>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import { api } from "@/api";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const err = ref("");
onMounted(async () => {
const urlParams = new URLSearchParams(window.location.search);
const code = (route.query.code as string) || urlParams.get("code");
const state = (route.query.state as string) || urlParams.get("state");
const ssoError = (route.query.error as string) || urlParams.get("error");
const ssoErrorDesc = (route.query.error_description as string) || urlParams.get("error_description");
if (ssoError) {
err.value = `SSO Error: ${ssoError} - ${ssoErrorDesc || "No description provided"}`;
return;
}
if (!code || !state) {
err.value = `Missing callback parameters. URL: ${window.location.search}`;
return;
}
try {
const res = await api.ssoCallback(code, state);
if (res.requires_setup) {
router.push("/setup-2fa");
return;
}
if (res.requires_2fa) {
router.push("/verify-2fa");
return;
}
await auth.fetchMe();
router.push("/");
} catch (e) {
err.value = e instanceof Error ? e.message : "SSO Login failed";
}
});
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-surface p-6">
<div class="card w-full max-w-md p-8 text-center">
<h1 class="text-2xl font-semibold">Single Sign-On</h1>
<p v-if="err" class="mt-4 text-red-500">{{ err }}</p>
<p v-else class="mt-4 text-slate-500">Completing sign-in</p>
<div v-if="err" class="mt-6">
<RouterLink to="/login" class="btn-primary inline-block w-full text-center">Back to login</RouterLink>
</div>
</div>
</div>
</template>
+12 -3
View File
@@ -4,14 +4,14 @@ import { api } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const form = ref({ org_name: "", org_logo: "" });
const form = ref({ org_name: "", org_logo: "", accent_color: "" });
const msg = ref("");
const err = ref("");
const busy = ref(false);
async function load() {
const data = await api.settings();
form.value = { org_name: data.org_name, org_logo: data.org_logo };
form.value = { org_name: data.org_name, org_logo: data.org_logo, accent_color: data.accent_color || "" };
}
onMounted(load);
@@ -22,7 +22,7 @@ async function save() {
busy.value = true;
try {
const data = await api.updateSettings(form.value);
form.value = { org_name: data.org_name, org_logo: data.org_logo };
form.value = { org_name: data.org_name, org_logo: data.org_logo, accent_color: data.accent_color || "" };
if (data.org) auth.org = data.org;
else await auth.fetchMe();
msg.value = "Settings saved";
@@ -57,6 +57,15 @@ async function save() {
<img :src="form.org_logo" alt="" class="h-10 rounded" @error="($event.target as HTMLImageElement).style.display = 'none'" />
</div>
<div>
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Accent Color</label>
<div class="flex items-center gap-3">
<input type="color" v-model="form.accent_color" class="h-10 w-16 cursor-pointer rounded border border-slate-200 bg-transparent p-0.5 dark:border-slate-700" />
<input v-model="form.accent_color" class="input-field font-mono text-sm w-32" placeholder="#1ebe8a" />
</div>
<p class="mt-1 text-xs text-slate-500">Hex format e.g. "#1ebe8a". Leave blank to use default.</p>
</div>
<div v-if="auth.can('manage_settings')" class="flex flex-wrap items-center gap-3">
<button type="submit" class="btn-primary" :disabled="busy">{{ busy ? "Saving…" : "Save" }}</button>
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p>
+1 -1
View File
@@ -230,7 +230,7 @@ async function delRole(id: number) {
<div v-if="showApiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showApiKey = ''">
<div class="card w-full max-w-md space-y-3">
<h2 class="text-lg font-semibold">New API key</h2>
<p class="text-sm text-slate-500">Copy this key now it won't be shown again.</p>
<p class="text-sm text-slate-500">Copy this key now - it won't be shown again.</p>
<code class="block break-all rounded-lg bg-surface-overlay p-3 text-sm">{{ showApiKey }}</code>
<button class="btn-primary" @click="showApiKey = ''">Done</button>
</div>
+2
View File
@@ -4,3 +4,5 @@ dotenv
gunicorn
pyotp
qrcode[pil]
Authlib
requests
+17 -4
View File
@@ -1,6 +1,19 @@
#!/bin/bash
set -e
echo "Building frontend..."
(cd frontend && npm ci && npm run build)
echo "Starting app..."
python app.py
echo "Building frontend UI..."
cd frontend
npm install
npm run build
cd ..
echo "Setting up Python virtual environment..."
if [ ! -d "venv" ]; then
python3 -m venv venv
fi
source venv/bin/activate
pip install -r requirements.txt
echo "Starting server..."
python app.py serve