feat: SSO

This commit is contained in:
2026-05-28 23:59:14 +00:00
parent fc5699a04c
commit 7526736e80
9 changed files with 442 additions and 27 deletions
+24
View File
@@ -26,6 +26,11 @@ export interface MeResponse {
org?: { name: string; logo: string };
user?: { id: number; name: string; email: string };
permissions?: string[];
features?: { sso?: boolean };
}
export interface SsoSettings {
is_sso_enabled: boolean;
}
export interface Device {
@@ -158,6 +163,15 @@ export const api = {
}),
);
},
async loginPrecheck(email: string, redirect?: string) {
return handle<{ method: "local" | "sso"; redirect_url?: string }>(
await fetchApi("/api/v2/auth/login/precheck", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ email, redirect }),
}),
);
},
async verify2fa(code: string, useBackup = false) {
return handle(await fetchApi("/api/v2/auth/verify-2fa", {
method: "POST",
@@ -177,6 +191,16 @@ export const api = {
async logout() {
return handle(await fetchApi("/api/v2/auth/logout", { method: "POST" }));
},
async ssoSettings() {
return handle<SsoSettings>(await fetchApi("/api/v2/settings/sso"));
},
async saveSsoSettings(body: { is_sso_enabled: boolean }) {
return handle<SsoSettings>(await fetchApi("/api/v2/settings/sso", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(body),
}));
},
async dashboard() {
return handle<{
stats: {
+2
View File
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore("auth", {
permissions: [] as string[],
org: { name: "IPAM", logo: "" },
version: "unknown",
features: { sso: false },
}),
getters: {
can: (state) => (perm: string) => state.permissions.includes(perm),
@@ -22,6 +23,7 @@ export const useAuthStore = defineStore("auth", {
this.permissions = data.permissions ?? [];
this.org = data.org ?? this.org;
this.version = data.app_version ?? "unknown";
this.features = data.features ?? { sso: false };
},
async login(email: string, password: string) {
return api.login(email, password);
+64 -7
View File
@@ -1,17 +1,51 @@
<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 SSO_ERRORS: Record<string, string> = {
sso_failed: "External sign-in failed. Please try again or use your password.",
sso_no_email: "External sign-in did not return an email address. Contact your administrator.",
sso_email_mismatch: "The signed-in account does not match the email you entered.",
sso_user_not_found: "No account exists for that email. Contact your administrator.",
};
const email = ref("");
const password = ref("");
const err = ref("");
const busy = ref(false);
const step = ref<"email" | "password">("email");
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
async function submit() {
onMounted(() => {
const code = route.query.error;
if (typeof code === "string" && code in SSO_ERRORS) {
err.value = SSO_ERRORS[code];
}
});
async function continueWithEmail() {
err.value = "";
busy.value = true;
try {
const redirect = (route.query.redirect as string) || undefined;
const r = await api.loginPrecheck(email.value.trim(), redirect);
if (r.method === "sso" && r.redirect_url) {
window.location.href = r.redirect_url;
return;
}
step.value = "password";
} catch (e) {
err.value = e instanceof Error ? e.message : "Login failed";
} finally {
busy.value = false;
}
}
async function submitPassword() {
err.value = "";
busy.value = true;
try {
@@ -32,23 +66,46 @@ async function submit() {
busy.value = false;
}
}
function backToEmail() {
step.value = "email";
password.value = "";
err.value = "";
}
</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">
<h1 class="text-2xl font-semibold">Sign in</h1>
<p class="mt-1 text-sm text-slate-500">Access your IP address management workspace.</p>
<form class="mt-8 space-y-4" @submit.prevent="submit">
<form class="mt-8 space-y-4" @submit.prevent="step === 'email' ? continueWithEmail() : submitPassword()">
<div>
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Email</label>
<input v-model="email" type="email" class="input-field" required autocomplete="username" />
<input
v-model="email"
type="email"
class="input-field"
required
autocomplete="username"
:readonly="step === 'password'"
/>
</div>
<div>
<div v-if="step === 'password'">
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Password</label>
<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" autofocus />
</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">
{{ busy ? "Please wait…" : step === "email" ? "Continue" : "Sign in" }}
</button>
<button
v-if="step === 'password'"
type="button"
class="btn-secondary w-full"
@click="backToEmail"
>
Back
</button>
</form>
</div>
</div>
+77 -3
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { api, type UserRow, type RoleRow } from "@/api";
import { ref, onMounted, computed, watch } from "vue";
import { api, type UserRow, type RoleRow, type SsoSettings } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const tab = ref<"users" | "roles">("users");
const tab = ref<"users" | "roles" | "sso">("users");
const showSsoTab = computed(() => auth.features?.sso && auth.can("view_admin"));
const users = ref<UserRow[]>([]);
const roles = ref<RoleRow[]>([]);
const permissions = ref<{ id: number; name: string; category?: string }[]>([]);
@@ -19,6 +20,46 @@ const editRoleId = ref<number | null>(null);
const roleForm = ref({ name: "", description: "", require_2fa: false, permission_ids: [] as number[] });
const showApiKey = ref("");
const ssoSettings = ref<SsoSettings | null>(null);
const ssoLoading = ref(false);
const ssoErr = ref("");
const ssoMsg = ref("");
const ssoForm = ref({ is_sso_enabled: false });
async function loadSso() {
if (!showSsoTab.value) return;
ssoLoading.value = true;
ssoErr.value = "";
try {
ssoSettings.value = await api.ssoSettings();
ssoForm.value = {
is_sso_enabled: ssoSettings.value.is_sso_enabled,
};
} catch (e) {
ssoErr.value = e instanceof Error ? e.message : "Failed to load SSO settings";
} finally {
ssoLoading.value = false;
}
}
watch(tab, (value) => {
if (value === "sso") loadSso();
});
async function saveSso() {
ssoErr.value = "";
ssoMsg.value = "";
try {
ssoSettings.value = await api.saveSsoSettings({
is_sso_enabled: ssoForm.value.is_sso_enabled,
});
ssoMsg.value = "SSO settings saved.";
} catch (e) {
ssoErr.value = e instanceof Error ? e.message : "Failed to save SSO settings";
}
}
const permByCategory = computed(() => {
const m: Record<string, typeof permissions.value> = {};
for (const p of permissions.value) {
@@ -132,6 +173,12 @@ async function delRole(id: number) {
<div class="mt-4 flex gap-2">
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'users' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'users'">Users</button>
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'roles' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'roles'">Roles</button>
<button
v-if="showSsoTab"
class="rounded-lg px-3 py-1 text-sm"
:class="tab === 'sso' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'"
@click="tab = 'sso'"
>SSO</button>
</div>
<section v-if="tab === 'users'" class="mt-8">
@@ -184,6 +231,33 @@ async function delRole(id: number) {
</ul>
</section>
<section v-if="tab === 'sso' && showSsoTab" class="mt-8 space-y-6">
<div>
<h2 class="font-semibold text-accent">Single sign-on</h2>
<p class="mt-1 text-sm text-slate-500">External sign-in for pre-provisioned users.</p>
</div>
<p v-if="ssoLoading" class="text-slate-500">Loading</p>
<template v-else>
<form class="card space-y-4" @submit.prevent="saveSso">
<h3 class="font-medium">External sign-in</h3>
<label class="flex items-center gap-2 text-sm">
<input v-model="ssoForm.is_sso_enabled" type="checkbox" />
Enable external sign-in for this deployment
</label>
<p class="text-sm text-slate-500">
When enabled, pre-provisioned users can sign in via <strong>Microsoft 365</strong> or
<strong>Discord</strong> through our identity provider. Local password login remains
available for users not routed to external sign-in.
</p>
<button type="submit" class="btn-primary text-sm">Save</button>
</form>
<p v-if="ssoMsg" class="text-sm text-emerald-600">{{ ssoMsg }}</p>
<p v-if="ssoErr" class="text-sm text-red-500">{{ ssoErr }}</p>
</template>
</section>
<div v-if="showUserForm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showUserForm = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveUser">
<h2 class="text-lg font-semibold">{{ editUserId ? "Edit user" : "Add user" }}</h2>