feat: ✨ add SSO authentication support, configurable brand accent colors, and update UI theme
This commit is contained in:
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
.env
|
.env
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
static/dist/
|
static/dist/
|
||||||
|
venv/
|
||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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) }),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -3,4 +3,6 @@ mysql-connector-python
|
|||||||
dotenv
|
dotenv
|
||||||
gunicorn
|
gunicorn
|
||||||
pyotp
|
pyotp
|
||||||
qrcode[pil]
|
qrcode[pil]
|
||||||
|
Authlib
|
||||||
|
requests
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user