feat(phase-4c): frontend, 13 pages, alpine+tailwind, tests
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* CRM Frontend API-Wrapper
|
||||
*
|
||||
* Lightweight fetch() wrapper with:
|
||||
* - Automatic JWT injection (Authorization: Bearer <token> from localStorage)
|
||||
* - 401 handling: clears token + redirects to /index.html
|
||||
* - JSON-only requests (FormData→x-www-form-urlencoded via postForm)
|
||||
* - ApiError class with status and parsed body for granular error handling
|
||||
*
|
||||
* All pages import this via <script type="module"> and use `api.get/post/patch/del`.
|
||||
*
|
||||
* NOTE: No x-html, no eval, no untrusted-string-template — XSS-safe by design.
|
||||
*/
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(response, body) {
|
||||
const message = body?.detail || body?.error?.message || `HTTP ${response.status}`;
|
||||
super(typeof message === 'string' ? message : JSON.stringify(message));
|
||||
this.name = 'ApiError';
|
||||
this.status = response.status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the JWT from localStorage.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getToken() {
|
||||
try {
|
||||
return localStorage.getItem('jwt');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the JWT in localStorage. Caller is responsible for also updating
|
||||
* Alpine.store('auth') so the UI stays in sync.
|
||||
* @param {string} token
|
||||
*/
|
||||
export function setToken(token) {
|
||||
try {
|
||||
if (token) localStorage.setItem('jwt', token);
|
||||
else localStorage.removeItem('jwt');
|
||||
} catch {
|
||||
// localStorage may be disabled in privacy mode — swallow silently
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
setToken(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core fetch wrapper. Adds JWT, handles 401 (redirect to login), parses JSON.
|
||||
*
|
||||
* @param {string} path Path under API_BASE, e.g. "/accounts/42"
|
||||
* @param {object} [options] fetch() options, with `body` already stringified if needed
|
||||
* @returns {Promise<any>} parsed JSON body, or null for 204
|
||||
* @throws {ApiError} on non-2xx status
|
||||
*/
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const token = getToken();
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
// Strip Content-Type for FormData/URLSearchParams bodies (let the browser set it)
|
||||
if (options.body instanceof URLSearchParams || options.body instanceof FormData) {
|
||||
delete headers['Content-Type'];
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
} catch (networkErr) {
|
||||
throw new ApiError(
|
||||
{ status: 0, statusText: 'Network Error' },
|
||||
{ detail: `Netzwerkfehler: ${networkErr.message}` }
|
||||
);
|
||||
}
|
||||
|
||||
// 401 → token invalid, force re-login (but not on the login page itself)
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
// Avoid redirect-loop when already on the login screen
|
||||
if (!window.location.pathname.endsWith('/index.html') &&
|
||||
window.location.pathname !== '/' &&
|
||||
!window.location.pathname.endsWith('/')) {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new ApiError(response, body);
|
||||
}
|
||||
|
||||
// 204 No Content (e.g. DELETE)
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to parse JSON for everything else
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = { detail: text };
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response, body);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience API. Pages import `api` and call `api.get('/accounts')` etc.
|
||||
*/
|
||||
export const api = {
|
||||
/** GET request */
|
||||
get: (path, options = {}) => apiFetch(path, { ...options, method: 'GET' }),
|
||||
|
||||
/** POST JSON */
|
||||
post: (path, data, options = {}) =>
|
||||
apiFetch(path, { ...options, method: 'POST', body: JSON.stringify(data) }),
|
||||
|
||||
/** PATCH JSON */
|
||||
patch: (path, data, options = {}) =>
|
||||
apiFetch(path, { ...options, method: 'PATCH', body: JSON.stringify(data) }),
|
||||
|
||||
/** DELETE (no body) */
|
||||
del: (path, options = {}) => apiFetch(path, { ...options, method: 'DELETE' }),
|
||||
|
||||
/** POST application/x-www-form-urlencoded (for OAuth2-style login if needed) */
|
||||
postForm: (path, params, options = {}) => {
|
||||
let body;
|
||||
if (params instanceof URLSearchParams) {
|
||||
body = params;
|
||||
} else {
|
||||
body = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params || {})) {
|
||||
if (v !== undefined && v !== null) body.append(k, String(v));
|
||||
}
|
||||
}
|
||||
return apiFetch(path, { ...options, method: 'POST', body });
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Auth helper functions (login, register, logout, refresh).
|
||||
*
|
||||
* These are plain functions that can be called from anywhere (Alpine-stores,
|
||||
* page-level handlers). They do NOT depend on Alpine — only on api.js and
|
||||
* localStorage.
|
||||
*
|
||||
* The Alpine store in store.js wraps these so the UI can react to state changes.
|
||||
*/
|
||||
|
||||
import { api, setToken, clearToken, getToken } from './api.js';
|
||||
|
||||
/**
|
||||
* Login with email + password.
|
||||
* Backend: POST /api/v1/auth/login → {access_token, token_type, expires_in}
|
||||
*
|
||||
* @param {string} email
|
||||
* @param {string} password
|
||||
* @returns {Promise<{access_token: string, token_type: string, expires_in: number}>}
|
||||
*/
|
||||
export async function login(email, password) {
|
||||
const res = await api.post('/auth/login', { email, password });
|
||||
if (res && res.access_token) {
|
||||
setToken(res.access_token);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user (first-admin bootstrap or admin invites a sales-rep).
|
||||
* Backend: POST /api/v1/auth/register → {user, access_token, token_type, expires_in}
|
||||
*
|
||||
* @param {{email: string, password: string, name: string, role?: string}} payload
|
||||
*/
|
||||
export async function register(payload) {
|
||||
const res = await api.post('/auth/register', payload);
|
||||
if (res && res.access_token) {
|
||||
setToken(res.access_token);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout: tell the backend (best-effort), clear local JWT, redirect to login.
|
||||
* Backend: POST /api/v1/auth/logout → {message: "logged out"}
|
||||
*/
|
||||
export async function logout() {
|
||||
if (getToken()) {
|
||||
try {
|
||||
await api.post('/auth/logout', {});
|
||||
} catch {
|
||||
// best-effort — even if the backend is down, we still clear the local token
|
||||
}
|
||||
}
|
||||
clearToken();
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the JWT using the current (still-valid) token.
|
||||
* Backend: POST /api/v1/auth/refresh → {access_token, token_type, expires_in}
|
||||
*
|
||||
* @returns {Promise<{access_token: string, token_type: string, expires_in: number}>}
|
||||
*/
|
||||
export async function refreshToken() {
|
||||
const res = await api.post('/auth/refresh', {});
|
||||
if (res && res.access_token) {
|
||||
setToken(res.access_token);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the currently-authenticated user's profile.
|
||||
* Backend: GET /api/v1/users/me → UserOut
|
||||
*
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function fetchCurrentUser() {
|
||||
return api.get('/users/me');
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Toast-Notification Component (Alpine.js)
|
||||
*
|
||||
* Renders the global notification queue from $store.notifications as a stack
|
||||
* of toasts in the top-right corner. Pages include this component exactly once,
|
||||
* typically in a shared layout fragment (e.g. app.html and index.html).
|
||||
*
|
||||
* Usage in HTML:
|
||||
* <div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||
* <template x-for="n in items" :key="n.id">
|
||||
* <div class="crm-toast" :class="'toast-' + n.type"
|
||||
* @click="dismiss(n.id)" x-text="n.message"></div>
|
||||
* </template>
|
||||
* </div>
|
||||
*
|
||||
* R-5: x-text only, never x-html.
|
||||
*/
|
||||
|
||||
export function toastContainer() {
|
||||
return {
|
||||
get items() {
|
||||
return window.Alpine ? Alpine.store('notifications').items : [];
|
||||
},
|
||||
dismiss(id) {
|
||||
if (window.Alpine) Alpine.store('notifications').dismiss(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Register the component as a global Alpine.data() factory. Pages can use
|
||||
// `x-data="toastContainer()"` after this module is imported.
|
||||
if (typeof window !== 'undefined') {
|
||||
document.addEventListener('alpine:init', () => {
|
||||
if (window.Alpine && !Alpine.$data_registry) {
|
||||
// no-op: Alpine 3 stores factories on the global namespace
|
||||
}
|
||||
// Alpine 3: Alpine.data(name, factory) registers globally for x-data="name"
|
||||
if (window.Alpine) {
|
||||
window.Alpine.data('toastContainer', toastContainer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default toastContainer;
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Alpine.js global stores for the CRM frontend.
|
||||
*
|
||||
* Stores:
|
||||
* - $store.auth: current user, JWT, isAuthenticated, login/logout helpers
|
||||
* - $store.notifications: toast queue (success/error/info/warning)
|
||||
*
|
||||
* Pages access these as $store.auth.user, $store.notifications.success(msg), etc.
|
||||
*
|
||||
* IMPORTANT: This file is loaded as a module AFTER Alpine's CDN script. The
|
||||
* `alpine:init` event fires once Alpine is ready, at which point we register
|
||||
* the stores. Until then, $store.auth etc. are undefined.
|
||||
*
|
||||
* R-5: never use x-html. All user-derived strings go through x-text or
|
||||
* interpolation in templates.
|
||||
*/
|
||||
|
||||
import { api, setToken, clearToken, getToken } from './api.js';
|
||||
import { login as authLogin, register as authRegister,
|
||||
logout as authLogout, refreshToken as authRefresh,
|
||||
fetchCurrentUser as authFetchMe } from './auth.js';
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// === AUTH STORE =========================================================
|
||||
Alpine.store('auth', {
|
||||
token: getToken(),
|
||||
user: null,
|
||||
isAuthenticated: !!getToken(),
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
/** Called from x-init on protected pages */
|
||||
async init() {
|
||||
if (!this.isAuthenticated) {
|
||||
// Auth-gate: redirect to login
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
await this.fetchUser();
|
||||
},
|
||||
|
||||
async fetchUser() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
this.user = await authFetchMe();
|
||||
} catch (err) {
|
||||
// 401 is already handled by api.js (token cleared, redirect).
|
||||
// For other errors, store them and log out.
|
||||
this.error = err.message || 'Failed to load user';
|
||||
await this.logout();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async login(email, password) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
await authLogin(email, password);
|
||||
this.token = getToken();
|
||||
this.isAuthenticated = true;
|
||||
await this.fetchUser();
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Login failed';
|
||||
throw err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async register(payload) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
await authRegister(payload);
|
||||
this.token = getToken();
|
||||
this.isAuthenticated = true;
|
||||
await this.fetchUser();
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Registration failed';
|
||||
throw err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await authLogout();
|
||||
} catch {
|
||||
// best-effort
|
||||
} finally {
|
||||
this.token = null;
|
||||
this.user = null;
|
||||
this.isAuthenticated = false;
|
||||
clearToken();
|
||||
}
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
await authRefresh();
|
||||
this.token = getToken();
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Token refresh failed';
|
||||
await this.logout();
|
||||
}
|
||||
},
|
||||
|
||||
/** Convenience: is the current user an admin? */
|
||||
get isAdmin() {
|
||||
return this.user && this.user.role === 'admin';
|
||||
},
|
||||
});
|
||||
|
||||
// === NOTIFICATIONS STORE ================================================
|
||||
Alpine.store('notifications', {
|
||||
items: [],
|
||||
|
||||
/**
|
||||
* Push a new toast.
|
||||
* @param {string} message
|
||||
* @param {'info'|'success'|'error'|'warning'} [type='info']
|
||||
* @param {number} [timeout=5000] ms before auto-dismiss (0 = no auto-dismiss)
|
||||
*/
|
||||
push(message, type = 'info', timeout = 5000) {
|
||||
const id = Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
||||
this.items.push({ id, message: String(message), type });
|
||||
if (timeout > 0) {
|
||||
setTimeout(() => this.dismiss(id), timeout);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
dismiss(id) {
|
||||
this.items = this.items.filter(n => n.id !== id);
|
||||
},
|
||||
|
||||
success(msg, timeout) { return this.push(msg, 'success', timeout); },
|
||||
error(msg, timeout) { return this.push(msg, 'error', timeout); },
|
||||
info(msg, timeout) { return this.push(msg, 'info', timeout); },
|
||||
warning(msg, timeout) { return this.push(msg, 'warning', timeout); },
|
||||
});
|
||||
});
|
||||
|
||||
// Expose a tiny global helper so non-module code (e.g. inline page handlers)
|
||||
// can call notifications without an explicit import.
|
||||
window.crmNotify = (msg, type) => {
|
||||
if (window.Alpine && window.Alpine.store('notifications')) {
|
||||
return window.Alpine.store('notifications').push(msg, type || 'info');
|
||||
}
|
||||
console.warn('crmNotify called before Alpine was ready:', msg);
|
||||
};
|
||||
Reference in New Issue
Block a user