feat: ✨ add SSO authentication support, configurable brand accent colors, and update UI theme
This commit is contained in:
@@ -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
@@ -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) }),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user