feat: ✨ SSO
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user