feat: auth & admin
This commit is contained in:
+2
-1
@@ -1 +1,2 @@
|
||||
export const DEFAULT_API_BASE_URL = import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
|
||||
export const DEFAULT_API_BASE_URL =
|
||||
import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Multi-instance backend registry + per-backend scoped storage.
|
||||
*
|
||||
* The web UI is backend-agnostic: it can connect to any server speaking the
|
||||
* `/api/v1` contract. Everything we persist (auth tokens, cached prefs, …) is
|
||||
* therefore bound to the *instance it came from* — never global. Each saved
|
||||
* backend gets a stable `id` derived from its base URL, and every persisted key
|
||||
* lives under the `mcma:<id>:` namespace so switching backends can never mix
|
||||
* one server's session/cache into another's.
|
||||
*
|
||||
* Layout in localStorage:
|
||||
* mcma:instances -> Instance[] (the saved-backends registry)
|
||||
* mcma:activeInstance -> <id> (which one is current)
|
||||
* mcma:<id>:auth -> persisted auth slice (per-backend)
|
||||
* mcma:<id>:<key> -> any other scoped value
|
||||
*/
|
||||
|
||||
export interface Instance {
|
||||
id: string;
|
||||
baseUrl: string;
|
||||
name: string;
|
||||
lastUsedAt: number;
|
||||
}
|
||||
|
||||
const REGISTRY_KEY = 'mcma:instances';
|
||||
const ACTIVE_KEY = 'mcma:activeInstance';
|
||||
|
||||
// pre-multi-instance keys, migrated once on first load
|
||||
const LEGACY_URL_KEY = 'mcma_api_base_url';
|
||||
const LEGACY_AUTH_KEY = 'mcma_auth';
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
return url.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** Stable, readable id from a base URL — also serves as the storage namespace. */
|
||||
export function instanceIdFromUrl(url: string): string {
|
||||
const stripped = normalizeUrl(url)
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '');
|
||||
const slug = stripped.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return slug || 'default';
|
||||
}
|
||||
|
||||
function defaultName(baseUrl: string): string {
|
||||
try {
|
||||
const u = new URL(
|
||||
/^https?:\/\//.test(baseUrl) ? baseUrl : `http://${baseUrl}`,
|
||||
);
|
||||
return u.host || baseUrl;
|
||||
} catch {
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function readRegistry(): Instance[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(REGISTRY_KEY);
|
||||
return raw ? (JSON.parse(raw) as Instance[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeRegistry(list: Instance[]): void {
|
||||
try {
|
||||
localStorage.setItem(REGISTRY_KEY, JSON.stringify(list));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
/** Saved backends, most-recently-used first. */
|
||||
export function listInstances(): Instance[] {
|
||||
return readRegistry().sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
||||
}
|
||||
|
||||
/** Add (or refresh) a backend in the registry and return its record. */
|
||||
export function upsertInstance(url: string, name?: string): Instance {
|
||||
const baseUrl = normalizeUrl(url);
|
||||
const id = instanceIdFromUrl(baseUrl);
|
||||
const list = readRegistry();
|
||||
const existing = list.find((i) => i.id === id);
|
||||
const inst: Instance = {
|
||||
id,
|
||||
baseUrl,
|
||||
name: name ?? existing?.name ?? defaultName(baseUrl),
|
||||
lastUsedAt: Date.now(),
|
||||
};
|
||||
writeRegistry(
|
||||
existing ? list.map((i) => (i.id === id ? inst : i)) : [...list, inst],
|
||||
);
|
||||
return inst;
|
||||
}
|
||||
|
||||
/** Remove a backend and wipe every scoped key it owns. */
|
||||
export function removeInstance(id: string): void {
|
||||
writeRegistry(readRegistry().filter((i) => i.id !== id));
|
||||
clearScope(id);
|
||||
if (getActiveInstanceId() === id) {
|
||||
const next = listInstances()[0];
|
||||
if (next) setActiveInstanceId(next.id);
|
||||
else localStorage.removeItem(ACTIVE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveInstanceId(): string | null {
|
||||
return localStorage.getItem(ACTIVE_KEY);
|
||||
}
|
||||
|
||||
export function setActiveInstanceId(id: string): void {
|
||||
localStorage.setItem(ACTIVE_KEY, id);
|
||||
}
|
||||
|
||||
export function getActiveInstance(): Instance | null {
|
||||
const id = getActiveInstanceId();
|
||||
if (!id) return null;
|
||||
return readRegistry().find((i) => i.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** Build the namespaced localStorage key for a value scoped to a backend. */
|
||||
export function scopedKey(
|
||||
key: string,
|
||||
instanceId: string | null = getActiveInstanceId(),
|
||||
): string {
|
||||
return `mcma:${instanceId ?? 'none'}:${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-backend key/value store. Reads/writes are no-ops when no instance is
|
||||
* active, so callers never have to special-case the unconnected state.
|
||||
*/
|
||||
export const instanceStorage = {
|
||||
get(key: string): string | null {
|
||||
const id = getActiveInstanceId();
|
||||
return id ? localStorage.getItem(scopedKey(key, id)) : null;
|
||||
},
|
||||
set(key: string, value: string): void {
|
||||
const id = getActiveInstanceId();
|
||||
if (!id) return;
|
||||
try {
|
||||
localStorage.setItem(scopedKey(key, id), value);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
},
|
||||
remove(key: string): void {
|
||||
const id = getActiveInstanceId();
|
||||
if (id) localStorage.removeItem(scopedKey(key, id));
|
||||
},
|
||||
};
|
||||
|
||||
function clearScope(id: string): void {
|
||||
const prefix = `mcma:${id}:`;
|
||||
const toRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k && k.startsWith(prefix)) toRemove.push(k);
|
||||
}
|
||||
toRemove.forEach((k) => localStorage.removeItem(k));
|
||||
}
|
||||
|
||||
/** One-time migration of the old single-backend keys into the namespaced model. */
|
||||
function migrateLegacy(): void {
|
||||
try {
|
||||
const legacyUrl = localStorage.getItem(LEGACY_URL_KEY);
|
||||
if (!legacyUrl || readRegistry().length > 0) return;
|
||||
const inst = upsertInstance(legacyUrl);
|
||||
const legacyAuth = localStorage.getItem(LEGACY_AUTH_KEY);
|
||||
if (legacyAuth)
|
||||
localStorage.setItem(scopedKey('auth', inst.id), legacyAuth);
|
||||
setActiveInstanceId(inst.id);
|
||||
localStorage.removeItem(LEGACY_URL_KEY);
|
||||
localStorage.removeItem(LEGACY_AUTH_KEY);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
// Fold any pre-multi-instance keys into the namespaced model on first load.
|
||||
// We deliberately do NOT seed an instance from the env default: before the
|
||||
// user connects there is no active instance, getApiBaseUrl() falls back to the
|
||||
// env default, and the connect flow registers the real backend.
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
migrateLegacy();
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import { DEFAULT_API_BASE_URL } from './env';
|
||||
import {
|
||||
getActiveInstance,
|
||||
upsertInstance,
|
||||
setActiveInstanceId,
|
||||
} from './instances';
|
||||
|
||||
const STORAGE_KEY = 'mcma_api_base_url';
|
||||
|
||||
/**
|
||||
* Base-URL resolution. The active backend (chosen via ConnectPage) wins; if
|
||||
* none is active we fall back to the env default. See `instances.ts` for the
|
||||
* per-backend registry that backs this — every persisted value is namespaced
|
||||
* to the instance it came from.
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_API_BASE_URL;
|
||||
return getActiveInstance()?.baseUrl ?? DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
/** Register a backend and make it the active one (used by the connect flow). */
|
||||
export function setApiBaseUrl(url: string): void {
|
||||
localStorage.setItem(STORAGE_KEY, url);
|
||||
}
|
||||
|
||||
export function clearApiBaseUrl(): void {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
const inst = upsertInstance(url);
|
||||
setActiveInstanceId(inst.id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user