feat: add SSO authentication support, configurable brand accent colors, and update UI theme

This commit is contained in:
2026-07-06 18:20:01 +01:00
parent 1346e9e5f5
commit d476901a53
16 changed files with 382 additions and 33 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
__pycache__ __pycache__
.env .env
frontend/node_modules/ frontend/node_modules/
static/dist/ static/dist/
venv/
+194 -2
View File
@@ -17,6 +17,8 @@ from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address
import pyotp import pyotp
import qrcode import qrcode
import mysql.connector import mysql.connector
import requests
from authlib.jose import jwt, JsonWebKey
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import ( from flask import (
Flask, session, request, abort, jsonify, redirect, Flask, session, request, abort, jsonify, redirect,
@@ -1219,6 +1221,191 @@ def group_devices_by_site(devices):
# ── Auth & account (v2) ─────────────────────────────────────────────────────── # ── 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 authlib.jose import jwt, JsonWebKey
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
# SSO succeeded, check 2FA logic
cursor.execute('SELECT require_2fa FROM Role WHERE id = %s', (user['role_id'],))
role_result = cursor.fetchone()
role_requires_2fa = bool(role_result['require_2fa']) if role_result else False
user_wants_2fa = bool(user.get('totp_enabled'))
needs_2fa = role_requires_2fa or user_wants_2fa
if needs_2fa:
if user.get('two_fa_setup_complete'):
session['pending_user_id'] = user['id']
session.modified = True
return jsonify({'requires_2fa': True})
else:
session['pending_user_id_setup'] = user['id']
session.modified = True
return jsonify({'requires_setup': True})
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']) @app.route('/api/v2/auth/login', methods=['POST'])
def api_auth_login(): def api_auth_login():
data = json_body() data = json_body()
@@ -1458,7 +1645,9 @@ def api_info():
'id': get_current_user_id(), 'id': get_current_user_id(),
'name': current_user()['name'], 'name': current_user()['name'],
'email': current_user()['email'] '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 # Devices API
@@ -3058,6 +3247,7 @@ def api_get_settings():
return jsonify({ return jsonify({
'org_name': app.config['NAME'], 'org_name': app.config['NAME'],
'org_logo': app.config['LOGO_PNG'], 'org_logo': app.config['LOGO_PNG'],
'accent_color': app.config.get('ACCENT_COLOR') or '#1ebe8a',
}) })
@@ -3067,7 +3257,8 @@ def api_update_settings():
data = json_body() data = json_body()
name = (data.get('org_name') or '').strip() name = (data.get('org_name') or '').strip()
logo = (data.get('org_logo') 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: with get_db_connection(current_app) as conn:
add_audit_log( add_audit_log(
get_current_user_id(), get_current_user_id(),
@@ -3078,6 +3269,7 @@ def api_update_settings():
return jsonify({ return jsonify({
'org_name': name, 'org_name': name,
'org_logo': logo, 'org_logo': logo,
'accent_color': accent_color or '#1ebe8a',
'org': org_branding(), 'org': org_branding(),
}) })
+11 -1
View File
@@ -609,8 +609,10 @@ def run_v2_migrations(cursor, conn):
DEFAULT_ORG_NAME = 'JDB-NET' DEFAULT_ORG_NAME = 'JDB-NET'
DEFAULT_ORG_LOGO = 'https://assets.jdbnet.co.uk/projects/ipam.png' DEFAULT_ORG_LOGO = 'https://assets.jdbnet.co.uk/projects/ipam.png'
DEFAULT_ACCENT_COLOR = '#1ebe8a'
ORG_NAME_KEY = 'org_name' ORG_NAME_KEY = 'org_name'
ORG_LOGO_KEY = 'org_logo' ORG_LOGO_KEY = 'org_logo'
ACCENT_COLOR_KEY = 'accent_color'
def get_setting(cursor, key): def get_setting(cursor, key):
@@ -639,6 +641,7 @@ def load_org_settings(app):
try: try:
name = get_setting(cursor, ORG_NAME_KEY).strip() name = get_setting(cursor, ORG_NAME_KEY).strip()
logo = get_setting(cursor, ORG_LOGO_KEY).strip() logo = get_setting(cursor, ORG_LOGO_KEY).strip()
accent = get_setting(cursor, ACCENT_COLOR_KEY).strip()
if not name and env_name: if not name and env_name:
name = env_name name = env_name
@@ -652,24 +655,29 @@ def load_org_settings(app):
app.config['NAME'] = name app.config['NAME'] = name
app.config['LOGO_PNG'] = logo app.config['LOGO_PNG'] = logo
app.config['ACCENT_COLOR'] = accent
conn.commit() conn.commit()
finally: finally:
cursor.close() cursor.close()
conn.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) conn = get_db_connection(app)
cursor = conn.cursor() cursor = conn.cursor()
try: try:
set_setting(cursor, ORG_NAME_KEY, name) set_setting(cursor, ORG_NAME_KEY, name)
set_setting(cursor, ORG_LOGO_KEY, logo) set_setting(cursor, ORG_LOGO_KEY, logo)
if accent_color is not None:
set_setting(cursor, ACCENT_COLOR_KEY, accent_color)
conn.commit() conn.commit()
finally: finally:
cursor.close() cursor.close()
conn.close() conn.close()
app.config['NAME'] = name app.config['NAME'] = name
app.config['LOGO_PNG'] = logo app.config['LOGO_PNG'] = logo
if accent_color is not None:
app.config['ACCENT_COLOR'] = accent_color
def org_branding(app=None): def org_branding(app=None):
@@ -678,7 +686,9 @@ def org_branding(app=None):
app = current_app app = current_app
name = (app.config.get('NAME') or '').strip() name = (app.config.get('NAME') or '').strip()
logo = (app.config.get('LOGO_PNG') or '').strip() logo = (app.config.get('LOGO_PNG') or '').strip()
accent = (app.config.get('ACCENT_COLOR') or '').strip()
return { return {
'name': name or DEFAULT_ORG_NAME, 'name': name or DEFAULT_ORG_NAME,
'logo': logo or DEFAULT_ORG_LOGO, 'logo': logo or DEFAULT_ORG_LOGO,
'accent_color': accent or DEFAULT_ACCENT_COLOR,
} }
+27
View File
@@ -1,5 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { watch, onMounted } from "vue";
import { RouterView } from "vue-router"; 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> </script>
<template> <template>
<RouterView /> <RouterView />
+19 -4
View File
@@ -23,7 +23,7 @@ function fetchApi(path: string, init?: RequestInit) {
export interface MeResponse { export interface MeResponse {
logged_in: boolean; logged_in: boolean;
app_version?: string; app_version?: string;
org?: { name: string; logo: string }; org?: { name: string; logo: string; accent_color?: string };
user?: { id: number; name: string; email: string }; user?: { id: number; name: string; email: string };
permissions?: string[]; permissions?: string[];
} }
@@ -146,6 +146,21 @@ export interface AuditParams {
} }
export const api = { 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> { async me(): Promise<MeResponse> {
return handle(await fetchApi("/api/v2/auth/me")); return handle(await fetchApi("/api/v2/auth/me"));
}, },
@@ -400,10 +415,10 @@ export const api = {
return d.items; return d.items;
}, },
async settings() { 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 }) { async updateSettings(body: { org_name: string; org_logo: string; accent_color?: string }) {
return handle<{ org_name: string; org_logo: string; org?: { name: string; logo: 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) }), 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: "/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: "/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: "/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: "/", path: "/",
component: () => import("@/components/AppLayout.vue"), component: () => import("@/components/AppLayout.vue"),
+1 -1
View File
@@ -7,7 +7,7 @@ export const useAuthStore = defineStore("auth", {
loggedIn: false, loggedIn: false,
user: null as MeResponse["user"] | null, user: null as MeResponse["user"] | null,
permissions: [] as string[], permissions: [] as string[],
org: { name: "IPAM", logo: "" }, org: { name: "IPAM", logo: "", accent_color: "#1ebe8a" },
version: "unknown", version: "unknown",
}), }),
getters: { getters: {
+9 -9
View File
@@ -4,19 +4,19 @@
@layer base { @layer base {
:root { :root {
--surface: 248 250 252; --surface: 240 246 252;
--surface-raised: 255 255 255; --surface-raised: 255 255 255;
--surface-overlay: 241 245 249; --surface-overlay: 230 237 243;
--accent: 6 182 212; --accent: 30 190 138;
--accent-muted: 8 145 178; --accent-muted: 24 152 110;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--surface: 15 20 25; --surface: 13 17 23;
--surface-raised: 21 28 36; --surface-raised: 22 27 34;
--surface-overlay: 26 35 46; --surface-overlay: 33 38 45;
--accent: 34 211 238; --accent: 30 190 138;
--accent-muted: 6 182 212; --accent-muted: 24 152 110;
} }
} }
} }
+2 -2
View File
@@ -74,7 +74,7 @@ async function regenCodes() {
</ul> </ul>
</div> </div>
<div v-if="newBackupCodes.length"> <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"> <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> <li v-for="c in newBackupCodes" :key="c">{{ c }}</li>
</ul> </ul>
@@ -87,7 +87,7 @@ async function regenCodes() {
class="text-sm text-red-500 hover:underline" class="text-sm text-red-500 hover:underline"
@click="disable2fa" @click="disable2fa"
>Disable 2FA</button> >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> </div>
</template> </template>
<template v-else> <template v-else>
+2 -2
View File
@@ -36,7 +36,7 @@ const activity = ref<ActivityPoint[]>([]);
const donutStyle = computed(() => { const donutStyle = computed(() => {
const pct = stats.value?.utilization_percent ?? 0; 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))); const maxActivity = computed(() => Math.max(1, ...activity.value.map((a) => a.count)));
@@ -128,7 +128,7 @@ function formatHour(h: number) {
</div> </div>
<div class="card"> <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> <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 class="mt-4 flex h-40 items-end gap-0.5">
<div <div
+31 -2
View File
@@ -1,16 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router"; import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { api } from "@/api";
const email = ref(""); const email = ref("");
const password = ref(""); const password = ref("");
const err = ref(""); const err = ref("");
const busy = ref(false); const busy = ref(false);
const ssoEnabled = ref(false);
const ssoLoading = ref(false);
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
const route = useRoute(); 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() { async function submit() {
err.value = ""; err.value = "";
busy.value = true; busy.value = true;
@@ -48,7 +72,12 @@ async function submit() {
<input v-model="password" type="password" class="input-field" required autocomplete="current-password" /> <input v-model="password" type="password" class="input-field" required autocomplete="current-password" />
</div> </div>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p> <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> </form>
</div> </div>
</div> </div>
+50
View File
@@ -0,0 +1,50 @@
<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 code = route.query.code as string;
const state = route.query.state as string;
if (!code || !state) {
err.value = "Missing callback parameters.";
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"; import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore(); const auth = useAuthStore();
const form = ref({ org_name: "", org_logo: "" }); const form = ref({ org_name: "", org_logo: "", accent_color: "" });
const msg = ref(""); const msg = ref("");
const err = ref(""); const err = ref("");
const busy = ref(false); const busy = ref(false);
async function load() { async function load() {
const data = await api.settings(); 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); onMounted(load);
@@ -22,7 +22,7 @@ async function save() {
busy.value = true; busy.value = true;
try { try {
const data = await api.updateSettings(form.value); 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; if (data.org) auth.org = data.org;
else await auth.fetchMe(); else await auth.fetchMe();
msg.value = "Settings saved"; 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'" /> <img :src="form.org_logo" alt="" class="h-10 rounded" @error="($event.target as HTMLImageElement).style.display = 'none'" />
</div> </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"> <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> <button type="submit" class="btn-primary" :disabled="busy">{{ busy ? "Saving…" : "Save" }}</button>
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p> <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 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"> <div class="card w-full max-w-md space-y-3">
<h2 class="text-lg font-semibold">New API key</h2> <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> <code class="block break-all rounded-lg bg-surface-overlay p-3 text-sm">{{ showApiKey }}</code>
<button class="btn-primary" @click="showApiKey = ''">Done</button> <button class="btn-primary" @click="showApiKey = ''">Done</button>
</div> </div>
+3 -1
View File
@@ -3,4 +3,6 @@ mysql-connector-python
dotenv dotenv
gunicorn gunicorn
pyotp pyotp
qrcode[pil] qrcode[pil]
Authlib
requests
+17 -4
View File
@@ -1,6 +1,19 @@
#!/bin/bash #!/bin/bash
set -e set -e
echo "Building frontend..."
(cd frontend && npm ci && npm run build) echo "Building frontend UI..."
echo "Starting app..." cd frontend
python app.py 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