fix mobile screen issues
This commit is contained in:
41
.env.example
Normal file
41
.env.example
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# Krow frontend — example environment
|
||||||
|
#
|
||||||
|
# Copy to .env and adjust. .env is gitignored.
|
||||||
|
#
|
||||||
|
# cp .env.example .env
|
||||||
|
#
|
||||||
|
# Vite only exposes variables prefixed with VITE_ to client code, and it reads
|
||||||
|
# these at build/dev-server start — changing one needs a restart, not a reload.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Where the browser sends API requests.
|
||||||
|
#
|
||||||
|
# A SAME-ORIGIN PATH, not a host. The browser asks its own origin for
|
||||||
|
# /api/v1/..., and something on that origin forwards it to the Go API:
|
||||||
|
# in development the Vite proxy (see `server.proxy` in vite.config.js), in
|
||||||
|
# production the same web server that serves the built assets (see nginx.conf).
|
||||||
|
#
|
||||||
|
# browser → localhost:5173/api/v1 → Vite proxy → 127.0.0.1:8080/api/v1
|
||||||
|
#
|
||||||
|
# THIS MUST STAY A PATH. Pointing it at http://127.0.0.1:8080/api/v1 makes every
|
||||||
|
# request cross-site, and the session cookie stops working in two separate ways:
|
||||||
|
#
|
||||||
|
# 1. The cookie is SameSite=Lax, and a Lax cookie is not sent on a cross-site
|
||||||
|
# subresource request. A browser treats localhost:5173 and 127.0.0.1:8080
|
||||||
|
# as different sites, so the cookie would be set at login and then never
|
||||||
|
# sent again.
|
||||||
|
# 2. Because the transport sends `credentials: 'include'`, the browser
|
||||||
|
# requires `Access-Control-Allow-Credentials: true` on the preflight
|
||||||
|
# response. The API does not send it — deliberately, because the supported
|
||||||
|
# arrangement is same-origin — so Chrome discards the preflight and never
|
||||||
|
# dispatches the real request. The symptom is an OPTIONS that answers 204
|
||||||
|
# followed by a POST that never reaches the server at all.
|
||||||
|
#
|
||||||
|
# Both failures are silent from the page's point of view, which is why this
|
||||||
|
# comment is longer than the value.
|
||||||
|
VITE_API_BASE_URL=/api/v1
|
||||||
|
|
||||||
|
# Where the Vite dev proxy forwards /api. Only read by vite.config.js, never by
|
||||||
|
# client code. Change this if the Go API is not on its default address.
|
||||||
|
# VITE_API_PROXY_TARGET=http://127.0.0.1:8080
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,9 @@
|
|||||||
#env
|
#env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
# ...but the template belongs in the repository: it is the one place the API's
|
||||||
|
# location is documented, and a checkout with no .env needs it.
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
/logs
|
/logs
|
||||||
|
|||||||
@@ -3,16 +3,24 @@
|
|||||||
*
|
*
|
||||||
* The reference app talks to a Base44 backend through this module. The demo
|
* The reference app talks to a Base44 backend through this module. The demo
|
||||||
* keeps the module path, the export name, and the full method contract, and
|
* keeps the module path, the export name, and the full method contract, and
|
||||||
* swaps the transport for the local store in `store.js` and the local AI engine
|
* swaps the transport for the Go API in `httpClient.js` and the local AI engine
|
||||||
* in `aiEngine.js`. Nothing downstream — hooks, pages, components — knows or
|
* in `aiEngine.js`. Nothing downstream — hooks, pages, components — knows or
|
||||||
* cares, which is exactly the point: the seam stays where it was.
|
* cares, which is exactly the point: the seam stays where it was.
|
||||||
|
*
|
||||||
|
* Phase 2D moved the transport from a localStorage-backed store to HTTP:
|
||||||
|
*
|
||||||
|
* React → base44Client.js → HTTP → Go API → PostgreSQL
|
||||||
|
*
|
||||||
|
* The entity surface is unchanged. `store.js` and `seed.js` are no longer the
|
||||||
|
* source of data — the seeded dataset now lives in PostgreSQL, loaded by the
|
||||||
|
* backend's `make seed`. `seed.js` is still imported for one thing: the shape
|
||||||
|
* of the demo user's default preferences, which the synchronous accessor below
|
||||||
|
* needs before the first response arrives.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createEntity, initStore, resetStore } from './store';
|
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
|
||||||
import { invokeLLM, uploadFile } from './aiEngine';
|
import { invokeLLM, uploadFile } from './aiEngine';
|
||||||
import { DEMO_USER, seedData } from './seed';
|
import { DEMO_USER } from './seed';
|
||||||
|
|
||||||
initStore(seedData);
|
|
||||||
|
|
||||||
const ENTITY_NAMES = [
|
const ENTITY_NAMES = [
|
||||||
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
||||||
@@ -33,9 +41,28 @@ const entities = Object.fromEntries(
|
|||||||
|
|
||||||
/* ── Auth ──────────────────────────────────────────────────────────────── */
|
/* ── Auth ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last user the API returned.
|
||||||
|
*
|
||||||
|
* This is a **cache of `GET /me`**, not a store. The record itself lives in
|
||||||
|
* PostgreSQL; this exists for one reason, and it is not offline support.
|
||||||
|
*
|
||||||
|
* `auth.preferences()` is synchronous, and it has to stay synchronous:
|
||||||
|
* `AssistantPanelContext` decides whether Owliver starts open in a `useState`
|
||||||
|
* initialiser, during the first render, and `krowHooks.js:42` merges the same
|
||||||
|
* accessor under the async user so the first paint already has real values. One
|
||||||
|
* tick later is a visible flash of the wrong workspace — the panel opening on
|
||||||
|
* an account that turned it off, then closing.
|
||||||
|
*
|
||||||
|
* So the last known user is mirrored to localStorage and read back at module
|
||||||
|
* load, and `GET /me` refreshes it. On a return visit the synchronous read is
|
||||||
|
* already correct; on a first-ever visit it is the seeded defaults for exactly
|
||||||
|
* as long as the request takes, which is the same thing the old store did with
|
||||||
|
* an empty key.
|
||||||
|
*/
|
||||||
const SESSION_KEY = 'krow_demo_user';
|
const SESSION_KEY = 'krow_demo_user';
|
||||||
|
|
||||||
function loadUser() {
|
function readCachedUser() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(SESSION_KEY);
|
const raw = localStorage.getItem(SESSION_KEY);
|
||||||
return raw ? { ...DEMO_USER, ...JSON.parse(raw) } : { ...DEMO_USER };
|
return raw ? { ...DEMO_USER, ...JSON.parse(raw) } : { ...DEMO_USER };
|
||||||
@@ -44,98 +71,207 @@ function loadUser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentUser = loadUser();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes the session user, and says whether it actually landed.
|
* Mirrors the current user for the next page load's synchronous read.
|
||||||
*
|
*
|
||||||
* The old version was `try { setItem } catch {}` — a swallowed
|
* Failures are ignored, which is a real change from the old `persistUser` and a
|
||||||
* `QuotaExceededError` or a private-browsing refusal, and the caller was handed
|
* safe one. That function checked its write and reported failure because
|
||||||
* a user object indistinguishable from a successful write. For preferences that
|
* localStorage was the *only* copy — a swallowed `QuotaExceededError` was how
|
||||||
* is invisible; for `customSkills`, which is where every account-authored skill
|
* account-authored skills silently disappeared. Now the only copy is in
|
||||||
* definition lives, it is the whole "I saved it and it was gone" report: the
|
* PostgreSQL, and a refused mirror costs one render of default preferences, not
|
||||||
* toast said added, the list showed it, the reload did not.
|
* data.
|
||||||
*
|
|
||||||
* The read-back matters as much as the catch. A write can be accepted and then
|
|
||||||
* evicted, and a serialisation can land truncated; comparing what came back
|
|
||||||
* with what went in is the only way to know the record is really there.
|
|
||||||
*/
|
*/
|
||||||
function persistUser() {
|
function cacheUser() {
|
||||||
const payload = JSON.stringify(currentUser);
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(SESSION_KEY, payload);
|
localStorage.setItem(SESSION_KEY, JSON.stringify(currentUser));
|
||||||
} catch (error) {
|
} catch {
|
||||||
return { persisted: false, error };
|
// Private browsing or quota — the server still has the record.
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
if (localStorage.getItem(SESSION_KEY) !== payload) {
|
|
||||||
return { persisted: false, error: new Error('The session record did not survive the write.') };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return { persisted: false, error };
|
|
||||||
}
|
|
||||||
return { persisted: true, error: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the mirrored user.
|
||||||
|
*
|
||||||
|
* Signing out must not leave the next page load rendering the previous
|
||||||
|
* account's name and preferences out of localStorage while it waits for a
|
||||||
|
* `GET /me` that is going to 401.
|
||||||
|
*/
|
||||||
|
function forgetUser() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(SESSION_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore.
|
||||||
|
}
|
||||||
|
currentUser = { ...DEMO_USER };
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentUser = readCachedUser();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the last `GET /me` succeeded.
|
||||||
|
*
|
||||||
|
* A cache of the server's answer, not a decision. Nothing here grants access:
|
||||||
|
* the API refuses an unauthenticated request whatever this says, and a user who
|
||||||
|
* edits it in the console has changed a boolean in their own tab and nothing
|
||||||
|
* else. It exists because `isAuthenticated()` is synchronous.
|
||||||
|
*/
|
||||||
|
let authenticated = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first `GET /me`, shared.
|
||||||
|
*
|
||||||
|
* Started at module load so the synchronous accessor is corrected as early as
|
||||||
|
* possible, and shared so the eleven `me()` call sites that fire during the
|
||||||
|
* first render make one request between them rather than eleven.
|
||||||
|
*
|
||||||
|
* A 401 here is the ordinary state of a signed-out visitor, not a failure: the
|
||||||
|
* app opens on the login page and this request is how it finds that out.
|
||||||
|
*/
|
||||||
|
let hydration = request('GET', '/me')
|
||||||
|
.then((user) => {
|
||||||
|
currentUser = user;
|
||||||
|
authenticated = true;
|
||||||
|
cacheUser();
|
||||||
|
return user;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
authenticated = false;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
const auth = {
|
const auth = {
|
||||||
/** The demo is always signed in as the seeded employer/admin user. */
|
/**
|
||||||
|
* The signed-in user, from the session cookie.
|
||||||
|
*
|
||||||
|
* Throws when there is no session — a `KrowApiError` with `status: 401` — and
|
||||||
|
* that throw is the app's authentication check. `AuthContext` catches it and
|
||||||
|
* renders the login page. Nothing here decides who the user is; the server
|
||||||
|
* reads its own session table and answers.
|
||||||
|
*
|
||||||
|
* Joins the in-flight hydration if there is one, so the first render's
|
||||||
|
* callers share a request; refetches afterwards so a change made in another
|
||||||
|
* tab, or a session that has since expired, is picked up.
|
||||||
|
*/
|
||||||
async me() {
|
async me() {
|
||||||
return { ...currentUser };
|
if (hydration) {
|
||||||
|
const user = await hydration;
|
||||||
|
hydration = null;
|
||||||
|
if (user) {
|
||||||
|
authenticated = true;
|
||||||
|
return { ...user };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const user = await request('GET', '/me');
|
||||||
|
currentUser = user;
|
||||||
|
authenticated = true;
|
||||||
|
cacheUser();
|
||||||
|
return { ...user };
|
||||||
|
} catch (error) {
|
||||||
|
if (isUnauthenticated(error)) {
|
||||||
|
authenticated = false;
|
||||||
|
forgetUser();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs in and starts a session.
|
||||||
|
*
|
||||||
|
* The response body is the user. The session token is NOT in it — it arrives
|
||||||
|
* as an HttpOnly cookie the browser stores and this code cannot read, which
|
||||||
|
* is what stops a script on the page from stealing it. There is deliberately
|
||||||
|
* nothing here that writes a token anywhere.
|
||||||
|
*
|
||||||
|
* Every credential failure comes back as the same 401 with the same message,
|
||||||
|
* by design: telling the two apart would say whether an address is
|
||||||
|
* registered. The caller shows that message as-is.
|
||||||
|
*/
|
||||||
|
async login({ email, password, rememberMe = false }) {
|
||||||
|
const user = await request('POST', '/auth/login', {
|
||||||
|
body: { email, password, remember_me: Boolean(rememberMe) },
|
||||||
|
});
|
||||||
|
currentUser = user;
|
||||||
|
authenticated = true;
|
||||||
|
hydration = null;
|
||||||
|
cacheUser();
|
||||||
|
return { ...user };
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateMe(patch) {
|
async updateMe(patch) {
|
||||||
currentUser = { ...currentUser, ...patch };
|
const user = await request('PATCH', '/me', { body: patch });
|
||||||
persistUser();
|
currentUser = user;
|
||||||
return { ...currentUser };
|
cacheUser();
|
||||||
|
return { ...user };
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preferences, read synchronously.
|
* Preferences, read synchronously.
|
||||||
*
|
*
|
||||||
* `me()` is async because the real client fetches, but the panel provider has
|
* A plain read of the same record `me()` returns, defaulted with the shape
|
||||||
* to decide whether Owliver starts open during its first render — one tick
|
* from `seed.js` so a key the server has never stored still resolves. See the
|
||||||
* later is a visible flash of the wrong workspace. The session user is already
|
* note on `currentUser` for why this must not become async.
|
||||||
* hydrated from localStorage at module load, so this is a plain read of the
|
|
||||||
* same record `me()` returns, not a second copy of the state.
|
|
||||||
*/
|
*/
|
||||||
preferences() {
|
preferences() {
|
||||||
return { ...DEMO_USER.preferences, ...(currentUser.preferences || {}) };
|
return { ...DEMO_USER.preferences, ...(currentUser.preferences || {}) };
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges into the stored preferences and persists with the rest of the user.
|
* Merges into the stored preferences and persists them server-side.
|
||||||
*
|
*
|
||||||
* Returns the write's outcome alongside the record, rather than the record
|
* `PATCH /me/preferences` shallow-merges and returns the whole merged object,
|
||||||
* alone. Preferences are where account-authored skills live, so "did this
|
* which is where `customSkills` and `customAgents` — every account-authored
|
||||||
* survive the reload" is a question the caller has to be able to answer —
|
* definition — now live: `user_preferences.extra`, a real column in a real
|
||||||
* see `persistUser`.
|
* database rather than a browser key.
|
||||||
|
*
|
||||||
|
* The `{ user, persisted, error }` shape is kept because `saveFeedback.js`
|
||||||
|
* reads it. Over HTTP a write that did not land is a non-2xx and therefore a
|
||||||
|
* throw, so the success path is unconditionally `persisted: true` — the
|
||||||
|
* question the shape exists to answer is now answered by whether this
|
||||||
|
* function resolved at all.
|
||||||
*/
|
*/
|
||||||
async updatePreferences(patch) {
|
async updatePreferences(patch) {
|
||||||
currentUser = { ...currentUser, preferences: { ...auth.preferences(), ...patch } };
|
const preferences = await request('PATCH', '/me/preferences', { body: patch });
|
||||||
const write = persistUser();
|
currentUser = { ...currentUser, preferences };
|
||||||
return { user: { ...currentUser }, ...write };
|
cacheUser();
|
||||||
},
|
return { user: { ...currentUser }, persisted: true, error: null };
|
||||||
|
|
||||||
isAuthenticated() {
|
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* There is no identity provider to sign out of, so this clears the local
|
* Whether the last `GET /me` succeeded.
|
||||||
* session and returns to the requested page.
|
*
|
||||||
|
* Synchronous, and therefore only ever a cache of what the server last said.
|
||||||
|
* It is a hint for rendering, never a gate: every protected endpoint is
|
||||||
|
* refused by the API on its own authority regardless of this value.
|
||||||
*/
|
*/
|
||||||
logout(redirectTo = '/') {
|
isAuthenticated() {
|
||||||
|
return authenticated;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs out and returns to the requested page.
|
||||||
|
*
|
||||||
|
* The server revokes the session row and expires the cookie; this clears the
|
||||||
|
* cached copy of the user so a signed-out tab cannot render a stale name from
|
||||||
|
* localStorage. The redirect happens either way — a logout that could not
|
||||||
|
* reach the API must still leave the browser signed out locally, and the
|
||||||
|
* cookie it keeps will be refused by every request it is sent on.
|
||||||
|
*/
|
||||||
|
async logout(redirectTo = '/admin/login') {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(SESSION_KEY);
|
await request('POST', '/auth/logout');
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore.
|
// Already signed out, or the API is unreachable. Neither is a reason to
|
||||||
|
// keep the user looking at a signed-in page.
|
||||||
}
|
}
|
||||||
currentUser = { ...DEMO_USER };
|
authenticated = false;
|
||||||
window.location.href = typeof redirectTo === 'string' ? redirectTo : '/';
|
forgetUser();
|
||||||
|
window.location.href = typeof redirectTo === 'string' ? redirectTo : '/admin/login';
|
||||||
},
|
},
|
||||||
|
|
||||||
redirectToLogin() {
|
redirectToLogin() {
|
||||||
window.location.href = '/';
|
window.location.href = '/admin/login';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -158,8 +294,27 @@ const analytics = {
|
|||||||
|
|
||||||
export const base44 = { entities, auth, integrations, analytics };
|
export const base44 = { entities, auth, integrations, analytics };
|
||||||
|
|
||||||
/** Restores the shipped demo data, discarding local edits. */
|
/** Where the entity data actually comes from, for diagnostics. */
|
||||||
|
export { API_BASE_URL };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears local session state and reloads.
|
||||||
|
*
|
||||||
|
* The demo dataset is no longer the browser's to restore: it lives in
|
||||||
|
* PostgreSQL, and reseeding it is `make seed` in the `krow-backend` repository,
|
||||||
|
* which upserts the shipped fixture in one transaction. All this can still do
|
||||||
|
* is drop the cached user and reload, so it says so rather than reporting a
|
||||||
|
* reset it did not perform.
|
||||||
|
*/
|
||||||
export function resetDemoData() {
|
export function resetDemoData() {
|
||||||
resetStore(seedData);
|
try {
|
||||||
|
localStorage.removeItem(SESSION_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore.
|
||||||
|
}
|
||||||
|
console.info(
|
||||||
|
'[krow-demo] Local session cache cleared. Entity data lives in PostgreSQL — ' +
|
||||||
|
'restore the shipped dataset with `make seed` in krow-backend.'
|
||||||
|
);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|||||||
308
src/api/httpClient.js
Normal file
308
src/api/httpClient.js
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* HTTP transport for the entity API.
|
||||||
|
*
|
||||||
|
* This is the module that replaces `store.js`. It exposes the same
|
||||||
|
* `createEntity(name)` factory with the same six methods and the same
|
||||||
|
* signatures, so `base44Client.js` swaps one import and nothing above it
|
||||||
|
* changes — not a hook, not a page, not a component.
|
||||||
|
*
|
||||||
|
* Everything here is a faithful translation of what `store.js` did locally into
|
||||||
|
* what `docs/api-contract.md` specifies over the wire. Where the two could
|
||||||
|
* differ, the local behaviour wins, because the callers were written against
|
||||||
|
* it:
|
||||||
|
*
|
||||||
|
* - `list`/`filter` return a bare array; `get`/`create`/`update` return a bare
|
||||||
|
* object; `delete` returns `{ id }`. The API's `{ data, meta }` envelope is
|
||||||
|
* unwrapped here and never seen above.
|
||||||
|
* - A failure throws an `Error` whose `message` is the server's message,
|
||||||
|
* because `store.js` threw and several callers depend on the throw
|
||||||
|
* (`useQuery`'s `isError`, and half a dozen `.catch(() => …)` fallbacks).
|
||||||
|
* - The default `sort` and `limit` on every method are the ones `store.js`
|
||||||
|
* declared, so a call site that passes neither still gets what it always
|
||||||
|
* got.
|
||||||
|
*
|
||||||
|
* The one thing that is genuinely new is the failure mode. A local store could
|
||||||
|
* not be unreachable; an API can, and "Failed to fetch" names neither the
|
||||||
|
* problem nor the fix. `request` turns that into a message that says which URL
|
||||||
|
* did not answer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the API lives.
|
||||||
|
*
|
||||||
|
* A same-origin path, not a host. The browser asks its own origin for
|
||||||
|
* `/api/v1/...`; in development the Vite proxy forwards that to the Go API (see
|
||||||
|
* `vite.config.js`), and in production the same path is served by the same
|
||||||
|
* origin as the app (see `nginx.conf`).
|
||||||
|
*
|
||||||
|
* This is a requirement of the session cookie rather than a preference. The
|
||||||
|
* cookie is HttpOnly with SameSite=Lax, and a Lax cookie is not sent on a
|
||||||
|
* cross-site request — so a page on `localhost:5173` fetching
|
||||||
|
* `http://127.0.0.1:8080` would authenticate once at login and then be a
|
||||||
|
* stranger on every request after it.
|
||||||
|
*
|
||||||
|
* VITE_API_BASE_URL can still point somewhere else, and `credentials` below is
|
||||||
|
* set so that it works, but the cross-origin path needs CORS credentials
|
||||||
|
* configured on the server and is not the supported arrangement.
|
||||||
|
*/
|
||||||
|
const DEFAULT_BASE_URL = '/api/v1';
|
||||||
|
|
||||||
|
export const API_BASE_URL = String(
|
||||||
|
import.meta.env?.VITE_API_BASE_URL || DEFAULT_BASE_URL
|
||||||
|
).replace(/\/+$/, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shout if the API has been pointed at another origin.
|
||||||
|
*
|
||||||
|
* This exists because the failure it catches is silent and misleading. Set
|
||||||
|
* `VITE_API_BASE_URL` to `http://127.0.0.1:8080/api/v1` and the browser sends a
|
||||||
|
* preflight, the server answers it `204`, and then the real request is never
|
||||||
|
* dispatched at all — because `credentials: 'include'` obliges the browser to
|
||||||
|
* require `Access-Control-Allow-Credentials: true` on that preflight, and the
|
||||||
|
* API does not send it. The server log shows an OPTIONS and nothing else; the
|
||||||
|
* page shows a request that never completes. Nothing names the cause.
|
||||||
|
*
|
||||||
|
* Even if CORS were opened up, the session cookie is `SameSite=Lax` and would
|
||||||
|
* not be sent on a cross-site request, so login would appear to succeed once
|
||||||
|
* and then every subsequent request would arrive as a stranger.
|
||||||
|
*
|
||||||
|
* Development only: `import.meta.env.DEV` is statically replaced at build time,
|
||||||
|
* so this whole block is dropped from the production bundle.
|
||||||
|
*/
|
||||||
|
if (import.meta.env?.DEV && /^https?:\/\//i.test(API_BASE_URL)) {
|
||||||
|
const sameOrigin =
|
||||||
|
typeof window !== 'undefined' && API_BASE_URL.startsWith(window.location.origin);
|
||||||
|
if (!sameOrigin) {
|
||||||
|
console.error(
|
||||||
|
`[krow] VITE_API_BASE_URL is "${API_BASE_URL}", which is a different origin ` +
|
||||||
|
`from ${typeof window !== 'undefined' ? window.location.origin : 'this page'}. ` +
|
||||||
|
'The session cookie will not work: the login POST is blocked at the CORS ' +
|
||||||
|
'preflight, and a SameSite=Lax cookie would not be sent cross-site anyway. ' +
|
||||||
|
'Set VITE_API_BASE_URL=/api/v1 in .env and restart the dev server so requests ' +
|
||||||
|
'go through the Vite proxy.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity name → the contract's resource path (§1: kebab-case plural, mass nouns
|
||||||
|
* singular).
|
||||||
|
*
|
||||||
|
* Declared rather than derived. A rule that turns `AIInterview` into
|
||||||
|
* `ai-interviews` and `Staff` into `staff` and `UserActivity` into
|
||||||
|
* `user-activity` is three special cases wearing a trench coat, and a wrong
|
||||||
|
* guess here is a 404 at runtime instead of a mistake anyone can see.
|
||||||
|
*/
|
||||||
|
const RESOURCE_PATHS = {
|
||||||
|
JobPosting: 'job-postings',
|
||||||
|
JobApplication: 'job-applications',
|
||||||
|
AIInterview: 'ai-interviews',
|
||||||
|
Staff: 'staff',
|
||||||
|
WorkerProfile: 'worker-profiles',
|
||||||
|
Course: 'courses',
|
||||||
|
Badge: 'badges',
|
||||||
|
LearningPath: 'learning-paths',
|
||||||
|
Certification: 'certifications',
|
||||||
|
RoleCategory: 'role-categories',
|
||||||
|
UserActivity: 'user-activity',
|
||||||
|
Evidence: 'evidence',
|
||||||
|
User: 'users',
|
||||||
|
Assignment: 'assignments',
|
||||||
|
ShiftRecord: 'shift-records',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Request ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a query string with the contract's filter encoding (§6).
|
||||||
|
*
|
||||||
|
* An array value becomes a repeated parameter — `?status=applied&status=hired`
|
||||||
|
* — which the server reads as `col = ANY(...)`, matching `store.js`'s
|
||||||
|
* `want.includes(got)`. `undefined` is omitted entirely: `store.js` compared
|
||||||
|
* `got === undefined` against real values and matched nothing, and sending the
|
||||||
|
* string "undefined" would be a filter on a value no column holds.
|
||||||
|
*/
|
||||||
|
function queryString(params) {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
if (item !== undefined) search.append(key, String(item));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
search.append(key, String(value));
|
||||||
|
}
|
||||||
|
const encoded = search.toString();
|
||||||
|
return encoded ? `?${encoded}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error a non-2xx becomes.
|
||||||
|
*
|
||||||
|
* `message` is the server's message verbatim, because §5.1 makes it
|
||||||
|
* load-bearing: `store.js` threw `"<Entity> <id> not found"` and the API
|
||||||
|
* reproduces that string exactly. The code, HTTP status and per-field details
|
||||||
|
* ride along as properties — new information a local store never had, and
|
||||||
|
* additive, so nothing that only reads `.message` notices.
|
||||||
|
*/
|
||||||
|
function apiError(status, payload) {
|
||||||
|
const body = payload?.error;
|
||||||
|
const error = new Error(body?.message || `Request failed with status ${status}`);
|
||||||
|
error.name = 'KrowApiError';
|
||||||
|
error.status = status;
|
||||||
|
error.code = body?.code || 'internal';
|
||||||
|
error.details = body?.details || {};
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One request, unwrapped.
|
||||||
|
*
|
||||||
|
* Returns `payload.data`, so every caller above works in bare records exactly
|
||||||
|
* as it did against the local store. `meta` is deliberately dropped: nothing
|
||||||
|
* reads it (§4.2), and surfacing it would mean changing what the six methods
|
||||||
|
* return, which is the one thing Phase 2D must not do.
|
||||||
|
*/
|
||||||
|
async function request(method, path, { query, body } = {}) {
|
||||||
|
const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`;
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
// The session cookie is HttpOnly: this code cannot read it, attach it by
|
||||||
|
// hand, or store it. `credentials` is the only lever there is, and
|
||||||
|
// without it `fetch` omits cookies on cross-origin requests entirely.
|
||||||
|
// Same-origin — the supported arrangement — would send them anyway;
|
||||||
|
// saying so explicitly means the one line that makes authentication work
|
||||||
|
// is visible rather than implied.
|
||||||
|
credentials: 'include',
|
||||||
|
headers: body === undefined
|
||||||
|
? { Accept: 'application/json' }
|
||||||
|
: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} catch (cause) {
|
||||||
|
// A transport failure, not an API response: no status, no envelope. The
|
||||||
|
// browser's own message for this is "Failed to fetch", which says nothing
|
||||||
|
// about which server or why, and it is nearly always the same cause — the
|
||||||
|
// API is not running.
|
||||||
|
const error = new Error(
|
||||||
|
`Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` +
|
||||||
|
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
|
||||||
|
`(${method} ${path})`
|
||||||
|
);
|
||||||
|
error.name = 'KrowApiError';
|
||||||
|
error.status = 0;
|
||||||
|
error.code = 'unreachable';
|
||||||
|
error.details = {};
|
||||||
|
error.cause = cause;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) throw apiError(response.status, payload);
|
||||||
|
|
||||||
|
if (payload === null) {
|
||||||
|
const error = new Error(`${method} ${path} returned no JSON body`);
|
||||||
|
error.name = 'KrowApiError';
|
||||||
|
error.status = response.status;
|
||||||
|
error.code = 'internal';
|
||||||
|
error.details = {};
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The request helper, for the `auth` surface in `base44Client.js`. */
|
||||||
|
export { request };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when an error is the API saying "you are not signed in".
|
||||||
|
*
|
||||||
|
* A 401 is an ordinary, expected answer here — it is what every request gets
|
||||||
|
* before the first login and after a session expires — so callers need to tell
|
||||||
|
* it apart from a real failure rather than treating both as "something broke".
|
||||||
|
*/
|
||||||
|
export function isUnauthenticated(error) {
|
||||||
|
return Boolean(error) && (error.status === 401 || error.code === 'unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Entity API ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the client surface for one entity.
|
||||||
|
*
|
||||||
|
* Signature-compatible with `store.js`'s `createEntity`, defaults included. The
|
||||||
|
* defaults matter more than they look: `store.js` declared
|
||||||
|
* `list(sort = '-created_date', limit = 100)`, and several call sites rely on
|
||||||
|
* them rather than passing their own.
|
||||||
|
*/
|
||||||
|
export function createEntity(name) {
|
||||||
|
const path = RESOURCE_PATHS[name];
|
||||||
|
if (!path) throw new Error(`No API resource path is declared for entity ${name}`);
|
||||||
|
const base = `/${path}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
entityName: name,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `sort` is always sent, even when empty. `?sort=` is not the same as
|
||||||
|
* omitting it: the contract reads an explicit empty value as "no ordering",
|
||||||
|
* which is what `applySort` did with a falsy sort, while omitting it would
|
||||||
|
* apply the endpoint's default.
|
||||||
|
*/
|
||||||
|
async list(sort = '-created_date', limit = 100) {
|
||||||
|
return request('GET', base, { query: { sort, limit } });
|
||||||
|
},
|
||||||
|
|
||||||
|
async filter(query = {}, sort = '-created_date', limit = 100) {
|
||||||
|
// Spread first so a field genuinely named `sort`, `limit` or `offset`
|
||||||
|
// could never shadow the reserved parameters (§1 records that no column
|
||||||
|
// collides with them today; this keeps that true if one ever does).
|
||||||
|
return request('GET', base, { query: { ...query, sort, limit } });
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id) {
|
||||||
|
return request('GET', `${base}/${encodeURIComponent(id)}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data) {
|
||||||
|
return request('POST', base, { body: data });
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id, data) {
|
||||||
|
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id) {
|
||||||
|
return request('DELETE', `${base}/${encodeURIComponent(id)}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sequential creates, exactly as `store.js` did it.
|
||||||
|
*
|
||||||
|
* Not a batch endpoint and not `Promise.all`: the contract has no bulk
|
||||||
|
* write (§12.1), and doing them one at a time keeps the failure behaviour
|
||||||
|
* identical — the first rejection stops the run and the records before it
|
||||||
|
* are already written.
|
||||||
|
*/
|
||||||
|
async bulkCreate(records = []) {
|
||||||
|
const created = [];
|
||||||
|
for (const record of records) created.push(await this.create(record));
|
||||||
|
return created;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '@/lib/AuthContext';
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
|
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
|
||||||
|
|
||||||
@@ -9,8 +9,24 @@ const DefaultFallback = () => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `unauthenticatedElement` defaults to the login page.
|
||||||
|
*
|
||||||
|
* It used to default to `undefined`, which renders nothing. That was invisible
|
||||||
|
* while the data client reported the seeded user as permanently signed in and
|
||||||
|
* this branch was unreachable; now that `GET /me` can genuinely answer 401, the
|
||||||
|
* default is what a signed-out visitor actually sees, and a blank screen is not
|
||||||
|
* an acceptable answer to "you are not signed in".
|
||||||
|
*
|
||||||
|
* The attempted path travels in location state so signing in returns the
|
||||||
|
* visitor to where they were going.
|
||||||
|
*/
|
||||||
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }) {
|
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }) {
|
||||||
|
const location = useLocation();
|
||||||
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
||||||
|
const signedOut = unauthenticatedElement ?? (
|
||||||
|
<Navigate to="/admin/login" replace state={{ from: location.pathname }} />
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authChecked && !isLoadingAuth) {
|
if (!authChecked && !isLoadingAuth) {
|
||||||
@@ -26,11 +42,11 @@ export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthe
|
|||||||
if (authError.type === 'user_not_registered') {
|
if (authError.type === 'user_not_registered') {
|
||||||
return <UserNotRegisteredError />;
|
return <UserNotRegisteredError />;
|
||||||
}
|
}
|
||||||
return unauthenticatedElement;
|
return signedOut;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return unauthenticatedElement;
|
return signedOut;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
|
|||||||
@@ -23,12 +23,18 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
|
|||||||
<div className={cn('space-y-6', className)}>
|
<div className={cn('space-y-6', className)}>
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
|
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<div className="flex items-center gap-2.5">
|
{/* Wraps rather than compresses. The row carries three things of very
|
||||||
|
different lengths — the page name, its count and the status pill —
|
||||||
|
and on a 390px screen holding them on one line meant the count
|
||||||
|
breaking mid-phrase and the pill splitting into "Live / System".
|
||||||
|
Nothing wraps at any width where all three fit, so every desktop
|
||||||
|
layout is untouched. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1.5">
|
||||||
<h1 className="font-heading text-title-lg font-bold tracking-tight text-ink-1 sm:text-2xl">{title}</h1>
|
<h1 className="font-heading text-title-lg font-bold tracking-tight text-ink-1 sm:text-2xl">{title}</h1>
|
||||||
{/* A count of whatever the page is a list of, stated beside its
|
{/* A count of whatever the page is a list of, stated beside its
|
||||||
name rather than only inside the list. */}
|
name rather than only inside the list. */}
|
||||||
{meta && <span className="text-body-sm tabular-nums text-ink-4">{meta}</span>}
|
{meta && <span className="whitespace-nowrap text-body-sm tabular-nums text-ink-4">{meta}</span>}
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
|
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
|
||||||
Live System
|
Live System
|
||||||
</span>
|
</span>
|
||||||
@@ -55,7 +61,10 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
|
|||||||
export function SectionTitle({ id, title, meta, action, className }) {
|
export function SectionTitle({ id, title, meta, action, className }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
|
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
|
||||||
<div className="flex items-baseline gap-2">
|
{/* The count wraps under the section name rather than competing with it
|
||||||
|
for a 390px line. On any width where both fit — every desktop and
|
||||||
|
tablet layout — nothing wraps and the row is the row it always was. */}
|
||||||
|
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2">
|
||||||
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
|
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
|
||||||
{meta && <span className="text-caption text-ink-4">{meta}</span>}
|
{meta && <span className="text-caption text-ink-4">{meta}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import KrowAssistant from './KrowAssistant';
|
import KrowAssistant from './KrowAssistant';
|
||||||
import { useAssistantPanel } from './AssistantPanelContext';
|
import { useAssistantPanel } from './AssistantPanelContext';
|
||||||
import { ResizeDivider } from './ResizeDivider';
|
import { ResizeDivider } from './ResizeDivider';
|
||||||
|
import { useIsPhone, useViewportWidth, useVisualViewport } from './viewport';
|
||||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||||
|
|
||||||
const EXPANDED_WIDTH = 620;
|
const EXPANDED_WIDTH = 620;
|
||||||
@@ -18,32 +19,6 @@ const LEAD_PADDING = 8;
|
|||||||
const GUTTER = HANDLE_WIDTH + LEAD_PADDING;
|
const GUTTER = HANDLE_WIDTH + LEAD_PADDING;
|
||||||
/** Expanded must never dominate: the dashboard stays the primary experience. */
|
/** Expanded must never dominate: the dashboard stays the primary experience. */
|
||||||
const MAX_VIEWPORT_SHARE = 0.42;
|
const MAX_VIEWPORT_SHARE = 0.42;
|
||||||
/**
|
|
||||||
* Below this width there is no room for a column *beside* the dashboard — a
|
|
||||||
* 380px track on a 375px phone collapses `main` to nothing — so Owliver stacks
|
|
||||||
* underneath the page instead.
|
|
||||||
*
|
|
||||||
* Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline
|
|
||||||
* column out acceptably, so they keep the two-column workspace and only phones
|
|
||||||
* stack.
|
|
||||||
*/
|
|
||||||
const STACK_BREAKPOINT = 768;
|
|
||||||
|
|
||||||
/** Tracks viewport width so the panel can be clamped and the layout switched. */
|
|
||||||
function useViewportWidth() {
|
|
||||||
const [width, setWidth] = React.useState(() =>
|
|
||||||
typeof window === 'undefined' ? 1440 : window.innerWidth
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const onResize = () => setWidth(window.innerWidth);
|
|
||||||
window.addEventListener('resize', onResize);
|
|
||||||
return () => window.removeEventListener('resize', onResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return width;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The collapsed state — a compact docked trigger.
|
* The collapsed state — a compact docked trigger.
|
||||||
*
|
*
|
||||||
@@ -64,7 +39,12 @@ function CollapsedTrigger({ page, onRestore }) {
|
|||||||
onClick={onRestore}
|
onClick={onRestore}
|
||||||
aria-label={`Show the Owliver workspace for ${page}`}
|
aria-label={`Show the Owliver workspace for ${page}`}
|
||||||
aria-expanded={false}
|
aria-expanded={false}
|
||||||
className="group fixed bottom-5 right-5 z-30 inline-flex items-center gap-2 rounded-full border border-border
|
/* `bottom` is a `max()` against the bottom safe-area inset rather than a
|
||||||
|
flat 20px: on a phone with a home indicator a flat offset puts the pill
|
||||||
|
under the gesture bar, where the tap belongs to the OS. `env()` is 0
|
||||||
|
everywhere else, so desktop keeps the offset it always had. */
|
||||||
|
className="group fixed bottom-[max(1.25rem,env(safe-area-inset-bottom))] right-[max(1.25rem,env(safe-area-inset-right))]
|
||||||
|
z-30 inline-flex items-center gap-2 rounded-full border border-border
|
||||||
bg-surface py-2 pl-2 pr-3.5 shadow-md transition-[box-shadow,border-color] duration-base
|
bg-surface py-2 pl-2 pr-3.5 shadow-md transition-[box-shadow,border-color] duration-base
|
||||||
hover:border-krow-blue/40 hover:shadow-lg
|
hover:border-krow-blue/40 hover:shadow-lg
|
||||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||||
@@ -77,11 +57,105 @@ function CollapsedTrigger({ page, onRestore }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AssistantPanel — the Owliver workspace column the Admin layout renders.
|
* MobileWorkspace — Owliver on a phone.
|
||||||
*
|
*
|
||||||
* Owliver is part of the page on every supported route, so this is a column in
|
* The same `KrowAssistant`, the same context, the same runtime, the same
|
||||||
* the layout rather than an overlay: the dashboard reflows beside it instead of
|
* conversation. What changes is only where it is mounted: on a phone the
|
||||||
* being covered, and collapsing restores the original layout exactly.
|
* workspace is an overlay above the page rather than a column beside it.
|
||||||
|
*
|
||||||
|
* That is forced by arithmetic, not taste. The desktop workspace is
|
||||||
|
* `main + 400px`; at 375px the 400px track leaves `main` negative, so the two
|
||||||
|
* surfaces stop being a layout and start being a fight over the same pixels —
|
||||||
|
* which is exactly what the broken state was. An overlay takes the page out of
|
||||||
|
* that arithmetic entirely: the page stays `width: 100%` whether Owliver is
|
||||||
|
* open or closed, and there is never a reserved column standing empty.
|
||||||
|
*
|
||||||
|
* Three things this is deliberately not:
|
||||||
|
*
|
||||||
|
* - Not a second chat. Nothing about the assistant is re-implemented; this
|
||||||
|
* component is a positioned container and nothing else.
|
||||||
|
* - Not a takeover. It stops below the app header, so the reader can still see
|
||||||
|
* where they are and can still leave.
|
||||||
|
* - Not a fixed height. It is sized to `visualViewport` where that exists and
|
||||||
|
* to `100dvh` where it does not, so an open keyboard shortens the sheet
|
||||||
|
* instead of pushing the composer off the bottom of it.
|
||||||
|
*/
|
||||||
|
function MobileWorkspace({ context, onClose }) {
|
||||||
|
const viewport = useVisualViewport();
|
||||||
|
|
||||||
|
/* The page behind an overlay must not scroll: on a touch screen a drag that
|
||||||
|
starts on the scrim and lands on the page is otherwise indistinguishable
|
||||||
|
from scrolling the conversation, and the reader loses their place on both
|
||||||
|
surfaces at once. Restored exactly as found — another overlay may already
|
||||||
|
own it. */
|
||||||
|
React.useEffect(() => {
|
||||||
|
const { body } = document;
|
||||||
|
const previous = body.style.overflow;
|
||||||
|
body.style.overflow = 'hidden';
|
||||||
|
return () => { body.style.overflow = previous; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-x-0 top-0 z-50 h-[100dvh] md:hidden"
|
||||||
|
style={viewport ? { height: viewport.height, top: viewport.offsetTop } : undefined}
|
||||||
|
>
|
||||||
|
{/* Tapping the page dismisses, which is what a sheet over a page should
|
||||||
|
do. A button rather than a bare div so it is reachable without a
|
||||||
|
pointer. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close the Owliver workspace"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-ink-1/30 backdrop-blur-[2px] motion-safe:animate-fade-in"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* `top-14` is the header's own height: the sheet starts under the app
|
||||||
|
bar rather than over it, so navigation is never covered. `min-h-0` is
|
||||||
|
what lets the assistant's internal scroller own the overflow instead
|
||||||
|
of the sheet growing past the viewport. */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 top-14 flex min-h-0 flex-col px-3
|
||||||
|
pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Owliver workspace"
|
||||||
|
>
|
||||||
|
<KrowAssistant
|
||||||
|
key={context.id}
|
||||||
|
context={context}
|
||||||
|
/* Expanded is a desktop-only width state; there is no wider to go
|
||||||
|
here, so the control is not offered rather than offered and inert. */
|
||||||
|
expanded={false}
|
||||||
|
onClose={onClose}
|
||||||
|
onExpand={null}
|
||||||
|
onRestore={null}
|
||||||
|
className="h-full min-h-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AssistantPanel — the Owliver workspace the Admin layout renders.
|
||||||
|
*
|
||||||
|
* One panel, two presentations, chosen by how much room there is beside the
|
||||||
|
* page rather than by what kind of device is asking:
|
||||||
|
*
|
||||||
|
* ≥ 768px a column in the layout. Owliver is part of the page on every
|
||||||
|
* supported route, so the dashboard reflows beside it instead of
|
||||||
|
* being covered, and collapsing restores the original layout
|
||||||
|
* exactly. This is the protected desktop geometry and everything
|
||||||
|
* below describes it.
|
||||||
|
* < 768px an overlay (`MobileWorkspace`), because a 400px track does not fit
|
||||||
|
* beside anything on a 375px phone. The page is `width: 100%` in
|
||||||
|
* both states and never participates in a two-column width
|
||||||
|
* calculation it cannot satisfy.
|
||||||
|
*
|
||||||
|
* Both presentations mount the same `KrowAssistant` with the same context and
|
||||||
|
* read the same open/collapsed state, so there is one assistant in the product
|
||||||
|
* and one set of actions that change it.
|
||||||
*
|
*
|
||||||
* Five structural details matter, and every one of them was a bug at some point:
|
* Five structural details matter, and every one of them was a bug at some point:
|
||||||
*
|
*
|
||||||
@@ -111,10 +185,24 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
|||||||
setWidth, resetWidth, open, close, expand, restore,
|
setWidth, resetWidth, open, close, expand, restore,
|
||||||
} = useAssistantPanel();
|
} = useAssistantPanel();
|
||||||
const viewportWidth = useViewportWidth();
|
const viewportWidth = useViewportWidth();
|
||||||
|
const isPhone = useIsPhone();
|
||||||
|
|
||||||
// No assistant on this route: no column, no rail, no trace in the layout.
|
// No assistant on this route: no column, no rail, no trace in the layout.
|
||||||
if (!context) return null;
|
if (!context) return null;
|
||||||
|
|
||||||
|
/* Phones: Owliver is never a column, in either state.
|
||||||
|
Closed, the layout is one column and `main` has the whole viewport — there
|
||||||
|
is no reserved 400px gutter to leave a blank strip down the right. Open,
|
||||||
|
the workspace is an overlay, so the page keeps that full width underneath
|
||||||
|
rather than being asked to share it with a track wider than the phone.
|
||||||
|
Both states are rendered from the same panel state the desktop column uses,
|
||||||
|
so opening, collapsing and reopening are the same three actions here. */
|
||||||
|
if (isPhone) {
|
||||||
|
return isOpen
|
||||||
|
? <MobileWorkspace context={context} onClose={close} />
|
||||||
|
: <CollapsedTrigger page={context.page} onRestore={open} />;
|
||||||
|
}
|
||||||
|
|
||||||
/* Expanded overrides the dragged width; otherwise the user's own width wins.
|
/* Expanded overrides the dragged width; otherwise the user's own width wins.
|
||||||
Both are clamped against the viewport so the dashboard is never squeezed. */
|
Both are clamped against the viewport so the dashboard is never squeezed. */
|
||||||
const viewportCap = Math.round(viewportWidth * MAX_VIEWPORT_SHARE);
|
const viewportCap = Math.round(viewportWidth * MAX_VIEWPORT_SHARE);
|
||||||
@@ -131,25 +219,6 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
|||||||
|
|
||||||
const trackWidth = panelWidth + GUTTER;
|
const trackWidth = panelWidth + GUTTER;
|
||||||
|
|
||||||
/* Phones: stack Owliver under the dashboard. A 380px column beside the page is
|
|
||||||
not a layout at this width, and an overlay that opens on load would put a
|
|
||||||
sheet between the user and the page they asked for. */
|
|
||||||
if (viewportWidth < STACK_BREAKPOINT) {
|
|
||||||
return (
|
|
||||||
<aside aria-label="Owliver workspace" className="pb-6 pt-2">
|
|
||||||
<KrowAssistant
|
|
||||||
key={context.id}
|
|
||||||
context={context}
|
|
||||||
expanded={false}
|
|
||||||
onClose={close}
|
|
||||||
onExpand={null}
|
|
||||||
onRestore={null}
|
|
||||||
className="h-[32rem]"
|
|
||||||
/>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label="Owliver workspace"
|
aria-label="Owliver workspace"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { base44 } from '@/api/base44Client';
|
import { base44 } from '@/api/base44Client';
|
||||||
import { resolveAssistantContext } from './placement';
|
import { resolveAssistantContext } from './placement';
|
||||||
|
import { PHONE_QUERY } from './viewport';
|
||||||
import { PageContextProvider } from './PageContext';
|
import { PageContextProvider } from './PageContext';
|
||||||
import { AgentProvider } from './AgentContext';
|
import { AgentProvider } from './AgentContext';
|
||||||
|
|
||||||
@@ -58,6 +59,24 @@ function writeFlag(key, value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this session is starting on a phone.
|
||||||
|
*
|
||||||
|
* Read once, to pick the *default* — never to decide how the panel renders.
|
||||||
|
* That decision belongs to `AssistantPanel`, which watches the same query and
|
||||||
|
* re-renders when it changes; a resize past the breakpoint must move the panel
|
||||||
|
* between presentations without silently reopening or closing it.
|
||||||
|
*
|
||||||
|
* The same media query the layout switches on, imported rather than repeated.
|
||||||
|
*/
|
||||||
|
function startsOnPhone() {
|
||||||
|
try {
|
||||||
|
return window.matchMedia(PHONE_QUERY).matches;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AssistantPanelProvider({ role, pathname, children }) {
|
export function AssistantPanelProvider({ role, pathname, children }) {
|
||||||
/* Two different things, deliberately kept apart: the account preference is
|
/* Two different things, deliberately kept apart: the account preference is
|
||||||
whether Owliver *should* open by default, and the session flag is whether
|
whether Owliver *should* open by default, and the session flag is whether
|
||||||
@@ -65,8 +84,19 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
|||||||
panel collapsed for the afternoon does not rewrite the account setting, and
|
panel collapsed for the afternoon does not rewrite the account setting, and
|
||||||
an account with Owliver off does not get the panel pushed back at it on
|
an account with Owliver off does not get the panel pushed back at it on
|
||||||
every new tab. */
|
every new tab. */
|
||||||
|
/* The default differs by presentation, and only the default.
|
||||||
|
On the desktop workspace Owliver *is* part of the page, so it opens with
|
||||||
|
it. On a phone the workspace is an overlay (see AssistantPanel), and an
|
||||||
|
overlay that opens on load is a sheet between the reader and the page they
|
||||||
|
asked for — so a phone starts on the docked pill, one tap from the same
|
||||||
|
conversation. Everything after that first render is the shared state:
|
||||||
|
opening on a phone is the same `open()` the desktop header calls, and the
|
||||||
|
session remembers it either way. */
|
||||||
const [open, setOpen] = React.useState(
|
const [open, setOpen] = React.useState(
|
||||||
() => readFlag(OPEN_KEY, base44.auth.preferences().owliverDefault !== false)
|
() => readFlag(
|
||||||
|
OPEN_KEY,
|
||||||
|
!startsOnPhone() && base44.auth.preferences().owliverDefault !== false
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
|
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
|
||||||
const [width, setWidth] = React.useState(() => {
|
const [width, setWidth] = React.useState(() => {
|
||||||
|
|||||||
102
src/components/ai-assistant/viewport.js
Normal file
102
src/components/ai-assistant/viewport.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Viewport facts the Owliver workspace is laid out from.
|
||||||
|
*
|
||||||
|
* Separate from both the panel and its context because both need them: the
|
||||||
|
* panel decides which presentation to render, the provider decides which state
|
||||||
|
* to start in, and a breakpoint that two modules each keep their own copy of is
|
||||||
|
* a breakpoint that will eventually disagree with itself. Keeping it here also
|
||||||
|
* keeps the two from importing each other in a cycle.
|
||||||
|
*/
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Below this width there is no room for a column *beside* the dashboard — a
|
||||||
|
* 380px track on a 375px phone collapses `main` to nothing — so Owliver stops
|
||||||
|
* being a column at all and becomes an overlay over the page.
|
||||||
|
*
|
||||||
|
* Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline
|
||||||
|
* column out acceptably, so they keep the two-column workspace and only phones
|
||||||
|
* change presentation. Exported so the provider can start a phone with the
|
||||||
|
* workspace closed from the same number the layout switches on — two places
|
||||||
|
* asking "is this a phone" must never be able to disagree.
|
||||||
|
*/
|
||||||
|
export const STACK_BREAKPOINT = 768;
|
||||||
|
export const PHONE_QUERY = `(max-width: ${STACK_BREAKPOINT - 1}px)`;
|
||||||
|
|
||||||
|
/** Tracks viewport width so the panel can be clamped. */
|
||||||
|
export function useViewportWidth() {
|
||||||
|
const [width, setWidth] = React.useState(() =>
|
||||||
|
typeof window === 'undefined' ? 1440 : window.innerWidth
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onResize = () => setWidth(window.innerWidth);
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the shell is in its phone layout.
|
||||||
|
*
|
||||||
|
* `matchMedia` rather than a width comparison, so the switch happens on exactly
|
||||||
|
* the same boundary the stylesheet uses. `window.innerWidth` is not that
|
||||||
|
* boundary on a phone: when anything on the page overflows, mobile browsers
|
||||||
|
* widen the layout viewport to fit it, `innerWidth` reports the widened value,
|
||||||
|
* and the layout that was supposed to rescue the page reads the viewport as a
|
||||||
|
* tablet and hands it a 400px sidebar instead. A media query is evaluated
|
||||||
|
* against the media, not against the content, so it cannot be talked out of the
|
||||||
|
* mobile layout by the very overflow the mobile layout exists to prevent.
|
||||||
|
*/
|
||||||
|
export function useIsPhone() {
|
||||||
|
const [phone, setPhone] = React.useState(() => {
|
||||||
|
try {
|
||||||
|
return window.matchMedia(PHONE_QUERY).matches;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(PHONE_QUERY);
|
||||||
|
const onChange = (e) => setPhone(e.matches);
|
||||||
|
setPhone(mql.matches);
|
||||||
|
mql.addEventListener('change', onChange);
|
||||||
|
return () => mql.removeEventListener('change', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The height the user can actually see, while a keyboard is open.
|
||||||
|
*
|
||||||
|
* `100dvh` follows the address bar but knows nothing about the on-screen
|
||||||
|
* keyboard: with the keyboard up, a `100dvh` sheet keeps its full height and
|
||||||
|
* puts its own composer underneath the keys — the one control the sheet exists
|
||||||
|
* to offer. `visualViewport` is the region left over, so the sheet is sized to
|
||||||
|
* that and the composer stays on screen.
|
||||||
|
*
|
||||||
|
* Returns `null` where the API is missing, and the caller falls back to the
|
||||||
|
* `100dvh` class. Nothing here is a fixed pixel height.
|
||||||
|
*/
|
||||||
|
export function useVisualViewport() {
|
||||||
|
const [box, setBox] = React.useState(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (!vv) return undefined;
|
||||||
|
const update = () => setBox({ height: Math.round(vv.height), offsetTop: Math.round(vv.offsetTop) });
|
||||||
|
update();
|
||||||
|
vv.addEventListener('resize', update);
|
||||||
|
vv.addEventListener('scroll', update);
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener('resize', update);
|
||||||
|
vv.removeEventListener('scroll', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return box;
|
||||||
|
}
|
||||||
@@ -456,7 +456,7 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
className="overflow-hidden rounded-xl border border-border bg-surface"
|
className="overflow-hidden rounded-xl border border-border bg-surface"
|
||||||
>
|
>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-body-sm">
|
<table className="w-full min-w-[40rem] text-left text-body-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-subtle text-[10px] font-semibold uppercase tracking-wider text-ink-4">
|
<tr className="border-b border-border bg-surface-subtle text-[10px] font-semibold uppercase tracking-wider text-ink-4">
|
||||||
<th className="px-4 py-3">Rank</th>
|
<th className="px-4 py-3">Rank</th>
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export function DataTable({
|
|||||||
<LoadingOverlay show={refreshing} />
|
<LoadingOverlay show={refreshing} />
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse">
|
<table className="w-full min-w-[36rem] border-collapse">
|
||||||
{caption && <caption className="sr-only">{caption}</caption>}
|
{caption && <caption className="sr-only">{caption}</caption>}
|
||||||
|
|
||||||
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>
|
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>
|
||||||
|
|||||||
@@ -83,7 +83,14 @@ export function MetricStrip({ items = [], columns, className, loading = false })
|
|||||||
<div>
|
<div>
|
||||||
{/* Header: Label & Icon */}
|
{/* Header: Label & Icon */}
|
||||||
<div className="flex items-center justify-between gap-1.5">
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
<p className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-4">
|
{/* Wraps on a phone, clips on everything wider.
|
||||||
|
Two of these sit side by side at 320px, which leaves about
|
||||||
|
110px for the label — enough for "OPEN POSITIONS" over two
|
||||||
|
lines and not enough for it on one, so a flat `truncate`
|
||||||
|
turned every metric on the Control Center into "OPEN POSI…".
|
||||||
|
From `sm` the strip is three or six across with room for the
|
||||||
|
label on one line, which is where clipping was the intent. */}
|
||||||
|
<p className="min-w-0 line-clamp-2 text-[10px] font-bold uppercase tracking-wider text-ink-4 sm:line-clamp-1">
|
||||||
{item.label}
|
{item.label}
|
||||||
</p>
|
</p>
|
||||||
{IconComponent && (
|
{IconComponent && (
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ export function Modal({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
|
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={cn('p-0 gap-0 overflow-hidden max-h-[90vh] flex flex-col', WIDTHS[size], className)}
|
/* `dvh`, not `vh`: on a phone `100vh` is the *large* viewport, which
|
||||||
|
includes the browser chrome that is currently covering the bottom of
|
||||||
|
the screen — so a `90vh` dialog put its footer, and its confirm
|
||||||
|
button, under the address bar. */
|
||||||
|
className={cn('p-0 gap-0 overflow-hidden max-h-[calc(100dvh-2rem)] flex flex-col', WIDTHS[size], className)}
|
||||||
onInteractOutside={busy ? (e) => e.preventDefault() : undefined}
|
onInteractOutside={busy ? (e) => e.preventDefault() : undefined}
|
||||||
onEscapeKeyDown={busy ? (e) => e.preventDefault() : undefined}
|
onEscapeKeyDown={busy ? (e) => e.preventDefault() : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export default function ActivityLogTable({ activities }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-[13px]">
|
<table className="w-full min-w-[40rem] text-[13px]">
|
||||||
<thead className="bg-[#F9FAFB] text-[#6B7280]">
|
<thead className="bg-[#F9FAFB] text-[#6B7280]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left font-medium px-4 py-2.5">Event</th>
|
<th className="text-left font-medium px-4 py-2.5">Event</th>
|
||||||
|
|||||||
@@ -139,8 +139,16 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('border rounded-xl p-4 transition-all hover:shadow-sm', style.card)}>
|
<div className={cn('border rounded-xl p-4 transition-all hover:shadow-sm', style.card)}>
|
||||||
|
{/* The row wraps below `sm`, and only below `sm`.
|
||||||
|
Its fixed furniture — rank, portrait, score ring and the action
|
||||||
|
cluster — adds up to about 400px before the name has a single pixel,
|
||||||
|
so on a 320px phone the flexible middle was being squeezed to nothing
|
||||||
|
and the candidate's name truncated to one character while the buttons
|
||||||
|
hung off the side of the screen. Wrapping gives the name the width and
|
||||||
|
moves the controls to their own line; `sm:flex-nowrap` restores the
|
||||||
|
single desktop row exactly. */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-4 cursor-pointer"
|
className="flex flex-wrap items-center gap-3 cursor-pointer sm:flex-nowrap sm:gap-4"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
>
|
>
|
||||||
{/* Rank */}
|
{/* Rank */}
|
||||||
@@ -149,7 +157,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Avatar — 88-110px target size */}
|
{/* Avatar — 88-110px target size */}
|
||||||
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-2xl bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[15px] shrink-0 overflow-hidden shadow-sm">
|
<div className="w-16 h-16 sm:w-24 sm:h-24 rounded-2xl bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[15px] shrink-0 overflow-hidden shadow-sm">
|
||||||
<Image
|
<Image
|
||||||
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=200&h=200&fit=crop&crop=faces`}
|
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=200&h=200&fit=crop&crop=faces`}
|
||||||
alt={application.applicant_name}
|
alt={application.applicant_name}
|
||||||
@@ -158,8 +166,10 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info. `basis-40` rather than the flex default of 0 so the name has a
|
||||||
<div className="flex-1 min-w-0">
|
floor to claim on a phone — under it the row wraps instead of
|
||||||
|
shaving the one column that carries meaning. */}
|
||||||
|
<div className="flex-1 basis-40 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h3 className="font-semibold text-[#111827] text-[14px] truncate">{application.applicant_name}</h3>
|
<h3 className="font-semibold text-[#111827] text-[14px] truncate">{application.applicant_name}</h3>
|
||||||
<span className={cn('px-2 py-0.5 rounded-full text-[10px] font-medium', style.badge)}>{style.label}</span>
|
<span className={cn('px-2 py-0.5 rounded-full text-[10px] font-medium', style.badge)}>{style.label}</span>
|
||||||
@@ -201,76 +211,84 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Score */}
|
{/* Score and actions.
|
||||||
{status !== 'applied' && <ScoreRing score={score} />}
|
`sm:contents` dissolves this wrapper from the tablet breakpoint up,
|
||||||
|
so above `sm` the ring and the buttons are direct children of the
|
||||||
|
row exactly as they always were — same order, same gaps, same
|
||||||
|
desktop geometry. Below it they travel together onto the row's
|
||||||
|
second line. */}
|
||||||
|
<div className="flex w-full items-center justify-between gap-3 sm:contents">
|
||||||
|
{/* Score */}
|
||||||
|
{status !== 'applied' && <ScoreRing score={score} />}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* The full record, at the route that already serves it.
|
{/* The full record, at the route that already serves it.
|
||||||
Clicking the row expands a summary in place, which is the right
|
Clicking the row expands a summary in place, which is the right
|
||||||
default while comparing people — but the complete profile had no
|
default while comparing people — but the complete profile had no
|
||||||
way in from this list at all, so the same candidate opened from a
|
way in from this list at all, so the same candidate opened from a
|
||||||
recommendation and from here led to two different depths of the
|
recommendation and from here led to two different depths of the
|
||||||
same person. One link, one route, one profile. */}
|
same person. One link, one route, one profile. */}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate(`/admin/candidates/${application.id}`)}
|
|
||||||
className="hidden sm:inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12px] font-semibold text-[#0838E0]
|
|
||||||
transition-colors hover:bg-[#EEF3FE] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
|
|
||||||
>
|
|
||||||
View profile
|
|
||||||
<ArrowRight className="w-3 h-3" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{onAction && canTakeAction && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onAction(application)}
|
type="button"
|
||||||
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
|
onClick={() => navigate(`/admin/candidates/${application.id}`)}
|
||||||
|
className="hidden sm:inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12px] font-semibold text-[#0838E0]
|
||||||
|
transition-colors hover:bg-[#EEF3FE] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
|
||||||
>
|
>
|
||||||
{status === 'interview' ? 'AI Interview' : 'Interview'}
|
View profile
|
||||||
|
<ArrowRight className="w-3 h-3" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<DropdownMenu>
|
{onAction && canTakeAction && (
|
||||||
<DropdownMenuTrigger asChild>
|
<button
|
||||||
<button className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[#E5E7EB] bg-white hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium transition-colors">
|
onClick={() => onAction(application)}
|
||||||
Actions
|
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
|
||||||
<ChevronDown className="w-3.5 h-3.5" />
|
>
|
||||||
|
{status === 'interview' ? 'AI Interview' : 'Interview'}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
)}
|
||||||
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
|
<DropdownMenu>
|
||||||
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
<DropdownMenuTrigger asChild>
|
||||||
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
<button className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[#E5E7EB] bg-white hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium transition-colors">
|
||||||
<span className="text-[#374151] text-[13px] font-medium">Message</span>
|
Actions
|
||||||
</DropdownMenuItem>
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
</DropdownMenuTrigger>
|
||||||
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
|
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
|
||||||
<span className="text-[#059669] text-[13px] font-medium">Call</span>
|
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||||
</DropdownMenuItem>
|
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
||||||
|
<span className="text-[#374151] text-[13px] font-medium">Message</span>
|
||||||
<DropdownMenuItem onClick={handleSchedule} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
|
||||||
<Calendar className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
|
||||||
<span className="text-[#0838E0] text-[13px] font-medium">Set up interview</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{onHire && (
|
|
||||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
|
||||||
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
|
|
||||||
<span className="text-[#059669] text-[13px] font-medium">Hire candidate</span>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||||
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||||
<span className="text-[#DC2626] text-[13px] font-medium">Decline candidate</span>
|
<span className="text-[#059669] text-[13px] font-medium">Call</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
<DropdownMenuItem onClick={handleSchedule} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||||
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
<Calendar className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
||||||
<span className="text-[#DC2626] text-[13px] font-medium">Delete candidate</span>
|
<span className="text-[#0838E0] text-[13px] font-medium">Set up interview</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
{onHire && (
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||||
|
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||||
|
<span className="text-[#059669] text-[13px] font-medium">Hire candidate</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||||
|
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||||
|
<span className="text-[#DC2626] text-[13px] font-medium">Decline candidate</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||||
|
<span className="text-[#DC2626] text-[13px] font-medium">Delete candidate</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expanded && <CandidateExpandedDetails application={application} />}
|
{expanded && <CandidateExpandedDetails application={application} />}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
|
|||||||
const identityClass = identityVerified ? 'bg-[#EEF3FE] text-[#1E40AF]' : 'bg-[#FFF7ED] text-[#B45309]';
|
const identityClass = identityVerified ? 'bg-[#EEF3FE] text-[#1E40AF]' : 'bg-[#FFF7ED] text-[#B45309]';
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-0">
|
<DialogContent className="max-w-2xl max-h-[calc(100dvh-2rem)] overflow-y-auto p-0">
|
||||||
<DialogHeader className="sr-only">
|
<DialogHeader className="sr-only">
|
||||||
<DialogTitle>{profile.full_name} — KROW Identity</DialogTitle>
|
<DialogTitle>{profile.full_name} — KROW Identity</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ function TableSection({ data }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-xl border border-border">
|
<div className="overflow-x-auto rounded-xl border border-border">
|
||||||
<table className="w-full border-collapse text-body-sm">
|
<table className="w-full min-w-[32rem] border-collapse text-body-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-subtle">
|
<tr className="border-b border-border bg-surface-subtle">
|
||||||
{columns.map((c) => (
|
{columns.map((c) => (
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const DialogContent = React.forwardRef(({ className, children, ...props }, ref)
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg max-h-[calc(100dvh-2rem)] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:w-full data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}>
|
{...props}>
|
||||||
|
|||||||
@@ -195,6 +195,43 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground font-body;
|
@apply bg-background text-foreground font-body;
|
||||||
|
/* Horizontal safe area, declared once for the whole product.
|
||||||
|
`env()` resolves to 0 on every device without a notch or rounded corner,
|
||||||
|
so this is inert on a desktop browser and only ever adds the inset a
|
||||||
|
landscape phone actually needs. Applied to `body` rather than to each
|
||||||
|
shell because the shells differ and the inset does not. */
|
||||||
|
padding-left: env(safe-area-inset-left);
|
||||||
|
padding-right: env(safe-area-inset-right);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A grid column that is allowed to be narrower than its contents.
|
||||||
|
*
|
||||||
|
* This is the single largest source of horizontal overflow in the product,
|
||||||
|
* and it is structural rather than a mistake anyone made on a page.
|
||||||
|
*
|
||||||
|
* `grid gap-6 xl:grid-cols-2` — the shape used all over Admin — declares no
|
||||||
|
* column track below `xl`, so on a phone the browser creates one *implicit*
|
||||||
|
* column sized by `grid-auto-columns: auto`. An `auto` track's base size is
|
||||||
|
* the min-content of what it holds, and a track never shrinks below its base
|
||||||
|
* size: a 436px card in a 328px column simply overflows the column, the
|
||||||
|
* column overflows `main`, `main` overflows the shell, and the whole page
|
||||||
|
* ends up wider than the phone. Chrome then widens the layout viewport to
|
||||||
|
* match, which is why the symptom reads as "the desktop layout, clipped"
|
||||||
|
* rather than as one card sticking out.
|
||||||
|
*
|
||||||
|
* `minmax(0, 1fr)` is what Tailwind's own `grid-cols-1` compiles to, so this
|
||||||
|
* says: an implicit column behaves exactly like a declared one. Above the
|
||||||
|
* breakpoint the track is explicit (`grid-cols-*` sets
|
||||||
|
* `grid-template-columns`) and this rule does not participate, which is what
|
||||||
|
* keeps every desktop grid byte-identical.
|
||||||
|
*
|
||||||
|
* The alternative was adding `grid-cols-1` to ~35 call sites and to every
|
||||||
|
* grid written afterwards. This is the same fix, declared once, in the layer
|
||||||
|
* the rest of the layout contract already lives in.
|
||||||
|
*/
|
||||||
|
.grid {
|
||||||
|
grid-auto-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -183,7 +183,11 @@ export default function AdminLayout() {
|
|||||||
An opaque shell here would cover it and every Admin page would lose the
|
An opaque shell here would cover it and every Admin page would lose the
|
||||||
tint at once — which is exactly why the canvas is one layer and not a
|
tint at once — which is exactly why the canvas is one layer and not a
|
||||||
background repeated on each page. */}
|
background repeated on each page. */}
|
||||||
<div className="min-h-screen">
|
{/* `dvh` rather than `vh`: `100vh` on a phone is the viewport with the
|
||||||
|
browser chrome retracted, so a `min-h-screen` shell is always taller
|
||||||
|
than the screen actually showing it and every page starts life
|
||||||
|
scrollable by the height of the address bar. */}
|
||||||
|
<div className="min-h-dvh">
|
||||||
{/* Single bar header in the new Admin design system */}
|
{/* Single bar header in the new Admin design system */}
|
||||||
<header className="sticky top-0 z-40 border-b border-border bg-white/85 backdrop-blur-xl">
|
<header className="sticky top-0 z-40 border-b border-border bg-white/85 backdrop-blur-xl">
|
||||||
<div className="flex h-14 items-center justify-between gap-4 px-4 lg:px-6">
|
<div className="flex h-14 items-center justify-between gap-4 px-4 lg:px-6">
|
||||||
@@ -323,9 +327,20 @@ export default function AdminLayout() {
|
|||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
{/* Main layout body */}
|
{/* Main layout body.
|
||||||
|
|
||||||
|
One column on a phone and two from `md`, which is the same
|
||||||
|
breakpoint AssistantPanel switches presentation on: below it Owliver
|
||||||
|
is an overlay and contributes nothing to this row, so `main` has the
|
||||||
|
whole shell rather than sharing it with a track wider than the
|
||||||
|
screen.
|
||||||
|
|
||||||
|
The extra bottom padding is for the docked Owliver pill, which is
|
||||||
|
fixed to the bottom-right corner. Without it the last row of a list
|
||||||
|
sits underneath the pill and cannot be read. Phone only: the desktop
|
||||||
|
spacing is unchanged. */}
|
||||||
<div className="flex flex-col px-4 md:flex-row lg:px-6">
|
<div className="flex flex-col px-4 md:flex-row lg:px-6">
|
||||||
<main className="min-w-0 flex-1 py-6">
|
<main className="min-w-0 flex-1 pb-24 pt-6 md:py-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import { base44 } from '@/api/base44Client';
|
|||||||
/**
|
/**
|
||||||
* Auth context.
|
* Auth context.
|
||||||
*
|
*
|
||||||
* The reference app resolves an identity provider before rendering. The demo
|
* The single source of truth for "is anyone signed in", and it is not a flag
|
||||||
* has no provider, so it resolves the seeded user immediately — but it keeps
|
* this app sets. `checkUserAuth` calls `GET /me`; the server reads its own
|
||||||
* the same context shape (`isLoadingAuth`, `isLoadingPublicSettings`,
|
* session table and either returns a user or answers 401. Success means there
|
||||||
* `authError`, `navigateToLogin`, …) so `App.jsx`, `ProtectedRoute` and every
|
* is a session, failure means there is not, and there is no third answer this
|
||||||
* consumer stay unchanged.
|
* code can manufacture.
|
||||||
|
*
|
||||||
|
* The context shape is unchanged from the demo's version — `isLoadingAuth`,
|
||||||
|
* `isLoadingPublicSettings`, `authError`, `navigateToLogin` and the rest — so
|
||||||
|
* `App.jsx`, `ProtectedRoute` and every consumer stay as they were. What
|
||||||
|
* changed is where the answer comes from: a real session cookie rather than a
|
||||||
|
* client that reported the seeded user as permanently signed in.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const AuthContext = createContext(/** @type {any} */ (null));
|
const AuthContext = createContext(/** @type {any} */ (null));
|
||||||
@@ -27,6 +33,10 @@ export const AuthProvider = ({ children }) => {
|
|||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
} catch {
|
} catch {
|
||||||
|
// A 401 is the ordinary state of a signed-out visitor, and it is also
|
||||||
|
// what an expired session looks like on the first request after it
|
||||||
|
// lapses. Both mean the same thing here.
|
||||||
|
setUser(null);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingAuth(false);
|
setIsLoadingAuth(false);
|
||||||
@@ -38,10 +48,27 @@ export const AuthProvider = ({ children }) => {
|
|||||||
checkUserAuth();
|
checkUserAuth();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = (shouldRedirect = true) => {
|
/**
|
||||||
|
* Signs in.
|
||||||
|
*
|
||||||
|
* Resolves to the user on success and throws the API's error otherwise. The
|
||||||
|
* error's message is already the generic one the server chose — it does not
|
||||||
|
* say whether the address exists — so callers show it as-is rather than
|
||||||
|
* writing their own.
|
||||||
|
*/
|
||||||
|
const login = async ({ email, password, rememberMe = false }) => {
|
||||||
|
const currentUser = await base44.auth.login({ email, password, rememberMe });
|
||||||
|
setUser(currentUser);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setAuthChecked(true);
|
||||||
|
return currentUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async (redirectTo = '/admin/login') => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
base44.auth.logout(shouldRedirect ? '/' : undefined);
|
// Revokes the session server-side, expires the cookie, then redirects.
|
||||||
|
await base44.auth.logout(redirectTo);
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateToLogin = () => {
|
const navigateToLogin = () => {
|
||||||
@@ -59,6 +86,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
authError: null,
|
authError: null,
|
||||||
appPublicSettings: null,
|
appPublicSettings: null,
|
||||||
authChecked,
|
authChecked,
|
||||||
|
login,
|
||||||
logout,
|
logout,
|
||||||
navigateToLogin,
|
navigateToLogin,
|
||||||
checkUserAuth,
|
checkUserAuth,
|
||||||
|
|||||||
@@ -1,50 +1,49 @@
|
|||||||
/**
|
/**
|
||||||
* Admin sign-in state.
|
* Admin sign-in state.
|
||||||
*
|
*
|
||||||
* The demo's data client (`base44.auth`) reports the seeded user as permanently
|
* This file used to BE the Admin session: a `sessionStorage` flag, set by the
|
||||||
* signed in — there is no identity provider behind it. That is fine for the
|
* login page and read by the route guard, standing in for a real one. The
|
||||||
* Employer and Talent demos, but the Admin console is supposed to be reached
|
* comment on it said so plainly — "it is not security — a browser flag never
|
||||||
* through its own sign-in, and "always authenticated" would mean /admin opened
|
* is — it is the state a real session would occupy … so swapping in a real
|
||||||
* straight into the Control Center with the login page never seen.
|
* provider later means changing this file and nothing else."
|
||||||
*
|
*
|
||||||
* So Admin keeps a session marker of its own. It is deliberately thin: a single
|
* That swap has happened. The session is now a row in PostgreSQL, addressed by
|
||||||
* flag, set by the login page and read by the route guard. It is not security —
|
* an HttpOnly cookie this code cannot read, and the question "is anyone signed
|
||||||
* a browser flag never is — it is the state a real session would occupy, put in
|
* in" is answered by `GET /me` through `AuthContext` — see `AdminRoute`, which
|
||||||
* the one place that both the guard and the login screen can agree on, so the
|
* now asks the context instead of asking here.
|
||||||
* flow is right now and swapping in a real provider later means changing this
|
|
||||||
* file and nothing else.
|
|
||||||
*
|
*
|
||||||
* `sessionStorage`, not `localStorage`: closing the tab should end the session,
|
* The three functions survive because `AdminLayout` and the Admin `Profile`
|
||||||
* which is both the more defensible default for an administrative console and
|
* page call `endAdminSession()` on their sign-out buttons, and those files are
|
||||||
* what makes the login page the app's actual starting point.
|
* outside this phase. They are honest about doing nothing that matters: there
|
||||||
|
* is no local flag left that could disagree with the server, which is the point
|
||||||
|
* — a browser value that grants access is exactly the thing being removed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SESSION_KEY = 'krow_admin_session';
|
/**
|
||||||
|
* Whether this tab has signed in to the Admin console.
|
||||||
/** True when this tab has signed in to the Admin console. */
|
*
|
||||||
|
* @deprecated The server decides. Use `useAuth().isAuthenticated`, which
|
||||||
|
* reflects `GET /me`, rather than anything stored in the browser.
|
||||||
|
* @returns {boolean} always false — no local flag can grant admin access.
|
||||||
|
*/
|
||||||
export function hasAdminSession() {
|
export function hasAdminSession() {
|
||||||
try {
|
return false;
|
||||||
return sessionStorage.getItem(SESSION_KEY) === '1';
|
|
||||||
} catch {
|
|
||||||
// Private mode with storage denied: fail closed, so the login page shows.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Records a successful sign-in. */
|
/**
|
||||||
export function startAdminSession() {
|
* Records a successful sign-in.
|
||||||
try {
|
*
|
||||||
sessionStorage.setItem(SESSION_KEY, '1');
|
* @deprecated A no-op. Signing in is `POST /api/v1/auth/login`, and what it
|
||||||
} catch {
|
* produces is an HttpOnly cookie, not a value in this module.
|
||||||
// Nothing to do — the guard will simply ask for sign-in again.
|
*/
|
||||||
}
|
export function startAdminSession() {}
|
||||||
}
|
|
||||||
|
|
||||||
/** Clears the session. Used by sign-out. */
|
/**
|
||||||
export function endAdminSession() {
|
* Clears the session.
|
||||||
try {
|
*
|
||||||
sessionStorage.removeItem(SESSION_KEY);
|
* @deprecated A no-op, kept because the sign-out buttons in `AdminLayout` and
|
||||||
} catch {
|
* the Admin `Profile` page still call it before `base44.auth.logout()`. The
|
||||||
// Ignore.
|
* logout call is what ends the session: it revokes the row server-side and
|
||||||
}
|
* expires the cookie.
|
||||||
}
|
*/
|
||||||
|
export function endAdminSession() {}
|
||||||
|
|||||||
@@ -121,7 +121,10 @@ export function toPositionPayload(draft = {}, { status = 'active' } = {}) {
|
|||||||
engine compares it against assignment windows. Null is open-ended. */
|
engine compares it against assignment windows. Null is open-ended. */
|
||||||
duration_months: record.duration_months === '' || record.duration_months == null
|
duration_months: record.duration_months === '' || record.duration_months == null
|
||||||
? null
|
? null
|
||||||
: Number(record.duration_months),
|
: (Number(record.duration_months) || null),
|
||||||
|
start_date: record.start_date && String(record.start_date).trim() !== ''
|
||||||
|
? String(record.start_date).trim()
|
||||||
|
: null,
|
||||||
company: String(record.company || '').trim(),
|
company: String(record.company || '').trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Sparkles, ChevronDown, ChevronUp, Sliders, Loader2, Star } from 'lucide
|
|||||||
import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useJobPosting, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
|
import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useJobPosting, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||||
import { CERT_OPTIONS, CRITERIA_LABELS, ENGLISH_LEVELS, defaultPosition } from '@/lib/positionModel';
|
import { CERT_OPTIONS, CRITERIA_LABELS, ENGLISH_LEVELS, defaultPosition, toPositionPayload } from '@/lib/positionModel';
|
||||||
import { SkillRequirementsField } from '@/components/krow/SkillRequirementsField';
|
import { SkillRequirementsField } from '@/components/krow/SkillRequirementsField';
|
||||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||||
@@ -130,11 +130,11 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
|||||||
// Create draft if not exists
|
// Create draft if not exists
|
||||||
let id = draftId;
|
let id = draftId;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
const draft = await createJob.mutateAsync({ ...form, status: 'draft', pay_range_min: Number(form.pay_range_min) || 0, pay_range_max: Number(form.pay_range_max) || 0 });
|
const draft = await createJob.mutateAsync(toPositionPayload(form, { status: 'draft' }));
|
||||||
id = draft.id;
|
id = draft.id;
|
||||||
setDraftId(id);
|
setDraftId(id);
|
||||||
}
|
}
|
||||||
const result = await generateDesc.mutateAsync({ data: { ...form, pay_range_min: Number(form.pay_range_min) || 0, pay_range_max: Number(form.pay_range_max) || 0 }, draftId: id });
|
const result = await generateDesc.mutateAsync({ data: toPositionPayload(form), draftId: id });
|
||||||
setAiResult(result);
|
setAiResult(result);
|
||||||
toast.success('Job description generated!');
|
toast.success('Job description generated!');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -153,11 +153,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
...form,
|
...toPositionPayload(form, { status }),
|
||||||
status,
|
|
||||||
company: String(form.company || '').trim(),
|
|
||||||
pay_range_min: Number(form.pay_range_min) || 0,
|
|
||||||
pay_range_max: Number(form.pay_range_max) || 0,
|
|
||||||
...(aiResult ? { description: aiResult.description, responsibilities: aiResult.responsibilities, qualifications: aiResult.qualifications, nice_to_haves: aiResult.nice_to_haves, ai_generated: true } : {}),
|
...(aiResult ? { description: aiResult.description, responsibilities: aiResult.responsibilities, qualifications: aiResult.qualifications, nice_to_haves: aiResult.nice_to_haves, ai_generated: true } : {}),
|
||||||
};
|
};
|
||||||
let createdId = draftId;
|
let createdId = draftId;
|
||||||
@@ -190,12 +186,15 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
|||||||
<div className={embedded ? '' : 'max-w-3xl mx-auto'}>
|
<div className={embedded ? '' : 'max-w-3xl mx-auto'}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-start justify-between gap-4 mb-6">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-[22px] font-bold text-[#111827]">Create a Position</h1>
|
<h1 className="text-[22px] font-bold text-[#111827]">Create a Position</h1>
|
||||||
<p className="text-[13px] text-[#6B7280] mt-0.5">Set hiring criteria once — KROW interviews and ranks every applicant automatically.</p>
|
<p className="text-[13px] text-[#6B7280] mt-0.5">Set hiring criteria once — KROW interviews and ranks every applicant automatically.</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => navigate('/admin/positions')} className="text-[13px] text-[#6B7280] hover:text-[#111827]">Cancel</button>
|
{/* `shrink-0` and a real gap: without them the subtitle's intrinsic
|
||||||
|
width pushed Cancel against the edge of a phone screen with the
|
||||||
|
title wrapping underneath it. */}
|
||||||
|
<button onClick={() => navigate('/admin/positions')} className="shrink-0 pt-1 text-[13px] text-[#6B7280] hover:text-[#111827]">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||||
import { hasAdminSession } from '@/lib/admin/session';
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate on the Admin console.
|
* Gate on the Admin console.
|
||||||
*
|
*
|
||||||
* Without this, `/admin` renders the Control Center for anyone who types the URL,
|
* It asks the auth context, which reflects `GET /me` — a real question to the
|
||||||
* because the demo's data client reports the seeded user as permanently signed in
|
* server about a real session — rather than the `sessionStorage` flag it used
|
||||||
* (see session.js). The console is meant to be entered through its own sign-in,
|
* to read. That flag could be set from the browser console; a session row keyed
|
||||||
* so the guard sends an unauthenticated visitor to the login page.
|
* by an HttpOnly cookie cannot.
|
||||||
|
*
|
||||||
|
* This guard is deliberately kept even though it now sits inside
|
||||||
|
* `ProtectedRoute`, which checks the same thing. It is not redundant defence in
|
||||||
|
* depth for its own sake: it is the component that owns "the Admin console is
|
||||||
|
* entered through its own sign-in", and it is where a role check will go in
|
||||||
|
* Phase 3D, when being signed in stops being sufficient to be here.
|
||||||
*
|
*
|
||||||
* The attempted path travels along in location state, so signing in returns you
|
* The attempted path travels along in location state, so signing in returns you
|
||||||
* to where you were going rather than dropping you on the Control Center. That
|
* to where you were going rather than dropping you on the Control Center. That
|
||||||
@@ -16,8 +22,14 @@ import { hasAdminSession } from '@/lib/admin/session';
|
|||||||
*/
|
*/
|
||||||
export default function AdminRoute() {
|
export default function AdminRoute() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { isAuthenticated, isLoadingAuth, authChecked } = useAuth();
|
||||||
|
|
||||||
if (!hasAdminSession()) {
|
// ProtectedRoute has already waited for the check, so this is belt and
|
||||||
|
// braces: without it a direct render of this route would redirect to the
|
||||||
|
// login page during the first tick and bounce a signed-in operator out.
|
||||||
|
if (isLoadingAuth || !authChecked) return null;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
return <Navigate to="/admin/login" replace state={{ from: location.pathname }} />;
|
return <Navigate to="/admin/login" replace state={{ from: location.pathname }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export default function AdminAnalytics() {
|
|||||||
/>
|
/>
|
||||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
|
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse text-body-sm">
|
<table className="w-full min-w-[36rem] border-collapse text-body-sm">
|
||||||
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
|
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-subtle">
|
<tr className="border-b border-border bg-surface-subtle">
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ export default function AdminCandidatesAnalysis() {
|
|||||||
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
|
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
|
||||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse text-body-sm">
|
<table className="w-full min-w-[40rem] border-collapse text-body-sm">
|
||||||
<caption className="sr-only">
|
<caption className="sr-only">
|
||||||
Applicant supply against candidates clearing the bar, per open role
|
Applicant supply against candidates clearing the bar, per open role
|
||||||
</caption>
|
</caption>
|
||||||
|
|||||||
@@ -922,7 +922,7 @@ export default function ControlCenter() {
|
|||||||
>
|
>
|
||||||
<div className="overflow-hidden rounded-xl border border-border bg-surface">
|
<div className="overflow-hidden rounded-xl border border-border bg-surface">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse">
|
<table className="w-full min-w-[36rem] border-collapse">
|
||||||
<caption className="sr-only">Most recent platform activity</caption>
|
<caption className="sr-only">Most recent platform activity</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-subtle">
|
<tr className="border-b border-border bg-surface-subtle">
|
||||||
|
|||||||
@@ -4,14 +4,27 @@ import { AlertCircle, ArrowRight, Loader2, Eye, EyeOff, Sparkles, CheckCircle2 }
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { KROW_LOGO_URL } from '@/assets/brand';
|
import { KROW_LOGO_URL } from '@/assets/brand';
|
||||||
import { Checkbox } from '@/components/ds';
|
import { Checkbox } from '@/components/ds';
|
||||||
import { base44 } from '@/api/base44Client';
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
import { startAdminSession } from '@/lib/admin/session';
|
|
||||||
|
|
||||||
const DEMO_EMAIL = 'demo@krow.app';
|
const DEMO_EMAIL = 'demo@krow.app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one message every failed sign-in shows.
|
||||||
|
*
|
||||||
|
* The API answers a wrong password, an unknown address, an account with no
|
||||||
|
* password and a suspended account with the same 401 and the same body, so that
|
||||||
|
* a stranger cannot learn which addresses are registered. Rendering the server's
|
||||||
|
* message verbatim would be equally correct; this is the same statement in the
|
||||||
|
* product's own voice, and — importantly — it is used for every failure, so this
|
||||||
|
* page does not reintroduce the distinction the API removes.
|
||||||
|
*/
|
||||||
|
const GENERIC_AUTH_ERROR =
|
||||||
|
'That email and password do not match an account. Check both and try again.';
|
||||||
|
|
||||||
export default function AdminLogin() {
|
export default function AdminLogin() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
const from = location.state?.from;
|
const from = location.state?.from;
|
||||||
const returnTo = typeof from === 'string' && from.startsWith('/admin') && from !== '/admin/login'
|
const returnTo = typeof from === 'string' && from.startsWith('/admin') && from !== '/admin/login'
|
||||||
@@ -28,8 +41,11 @@ export default function AdminLogin() {
|
|||||||
const next = {};
|
const next = {};
|
||||||
if (!form.email.trim()) next.email = 'Enter your work email';
|
if (!form.email.trim()) next.email = 'Enter your work email';
|
||||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) next.email = 'Enter a valid email address';
|
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) next.email = 'Enter a valid email address';
|
||||||
|
// Only "did you type anything". A client-side length rule here would be a
|
||||||
|
// guess at the server's policy that goes stale the moment the policy moves,
|
||||||
|
// and it tells someone with a valid short legacy password that their own
|
||||||
|
// password is invalid.
|
||||||
if (!form.password) next.password = 'Enter your password';
|
if (!form.password) next.password = 'Enter your password';
|
||||||
else if (form.password.length < 8) next.password = 'Passwords are at least 8 characters';
|
|
||||||
setErrors(next);
|
setErrors(next);
|
||||||
return Object.keys(next).length === 0;
|
return Object.keys(next).length === 0;
|
||||||
};
|
};
|
||||||
@@ -40,17 +56,26 @@ export default function AdminLogin() {
|
|||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
|
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
await new Promise((r) => setTimeout(r, 900));
|
try {
|
||||||
|
// The session is created server-side and arrives as an HttpOnly cookie.
|
||||||
if (form.password.toLowerCase() === 'wrong') {
|
// Nothing on this page sees, stores or forwards a token — there is no
|
||||||
|
// token here to mishandle.
|
||||||
|
await login({
|
||||||
|
email: form.email.trim(),
|
||||||
|
password: form.password,
|
||||||
|
rememberMe: form.remember,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setAuthError('That email and password do not match an administrator account.');
|
// One message for every rejection. A rate-limited attempt is the single
|
||||||
|
// exception worth distinguishing, because "wait a moment" is advice the
|
||||||
|
// person can act on and it reveals nothing about any account.
|
||||||
|
setAuthError(error?.status === 429
|
||||||
|
? (error.message || 'Too many sign-in attempts. Wait a few minutes and try again.')
|
||||||
|
: GENERIC_AUTH_ERROR);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await base44.auth.updateMe({ account_type: 'admin' });
|
|
||||||
localStorage.setItem('activeRole', 'admin');
|
|
||||||
startAdminSession();
|
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
setTimeout(() => navigate(returnTo, { replace: true }), 320);
|
setTimeout(() => navigate(returnTo, { replace: true }), 320);
|
||||||
};
|
};
|
||||||
@@ -192,7 +217,7 @@ export default function AdminLogin() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => setAuthError('Password resets are disabled in the demo.')}
|
onClick={() => setAuthError('Password resets are not available yet — ask an administrator to set a new password.')}
|
||||||
className="text-[#D97706] hover:text-[#B45309] font-semibold transition-colors"
|
className="text-[#D97706] hover:text-[#B45309] font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
@@ -213,7 +238,8 @@ export default function AdminLogin() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-xs text-[#94A3B8] leading-relaxed pt-2">
|
<p className="text-center text-xs text-[#94A3B8] leading-relaxed pt-2">
|
||||||
Demo environment — enter any 8+ character password to sign in. Use <span className="text-[#64748B] font-semibold">wrong</span> to test error validation.
|
Sign in with your Krow account. Passwords are set by an administrator with
|
||||||
|
the <span className="text-[#64748B] font-semibold">setpassword</span> utility.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,8 +91,10 @@ export default function AdminProfile() {
|
|||||||
<AdminPage
|
<AdminPage
|
||||||
title="Admin Profile"
|
title="Admin Profile"
|
||||||
subtitle="Manage your platform credentials, system permissions and security governance."
|
subtitle="Manage your platform credentials, system permissions and security governance."
|
||||||
|
/* Three buttons need 295px; a 320px phone has 273 after the page gutters,
|
||||||
|
so the row wraps instead of pushing Sign Out off the edge. */
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin/settings')}
|
onClick={() => navigate('/admin/settings')}
|
||||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-white border border-[#E0E4EA] text-[#101820] text-[13px] font-semibold hover:bg-[#F8F8F8] transition-all shadow-xs"
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-white border border-[#E0E4EA] text-[#101820] text-[13px] font-semibold hover:bg-[#F8F8F8] transition-all shadow-xs"
|
||||||
@@ -173,13 +175,13 @@ export default function AdminProfile() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick stats in Hero */}
|
{/* Quick stats in Hero */}
|
||||||
<div className="flex items-center gap-3 border-t md:border-t-0 md:border-l border-white/20 pt-4 md:pt-0 md:pl-6">
|
<div className="flex flex-wrap items-center gap-3 border-t md:border-t-0 md:border-l md:flex-nowrap border-white/20 pt-4 md:pt-0 md:pl-6">
|
||||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
<div className="flex-1 md:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Scopes</p>
|
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Scopes</p>
|
||||||
<p className="text-2xl font-bold text-white mt-0.5">{PERMISSIONS.length}</p>
|
<p className="text-2xl font-bold text-white mt-0.5">{PERMISSIONS.length}</p>
|
||||||
<span className="text-[10px] text-blue-200">Full Control</span>
|
<span className="text-[10px] text-blue-200">Full Control</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
<div className="flex-1 md:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Security Level</p>
|
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Security Level</p>
|
||||||
<p className="text-2xl font-bold text-emerald-300 mt-0.5">Tier 1</p>
|
<p className="text-2xl font-bold text-emerald-300 mt-0.5">Tier 1</p>
|
||||||
<span className="text-[10px] text-blue-200">Max Privileges</span>
|
<span className="text-[10px] text-blue-200">Max Privileges</span>
|
||||||
|
|||||||
@@ -55,18 +55,18 @@ export default function AdminSettings() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex flex-wrap items-center gap-3 sm:flex-nowrap sm:shrink-0">
|
||||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Active Staff</p>
|
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Active Staff</p>
|
||||||
<p className="text-2xl font-bold text-white mt-0.5">{staff.length}</p>
|
<p className="text-2xl font-bold text-white mt-0.5">{staff.length}</p>
|
||||||
<span className="text-[10px] text-blue-200">On Platform</span>
|
<span className="text-[10px] text-blue-200">On Platform</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Positions</p>
|
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Positions</p>
|
||||||
<p className="text-2xl font-bold text-emerald-300 mt-0.5">{postings.length}</p>
|
<p className="text-2xl font-bold text-emerald-300 mt-0.5">{postings.length}</p>
|
||||||
<span className="text-[10px] text-blue-200">Active Roles</span>
|
<span className="text-[10px] text-blue-200">Active Roles</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Clients</p>
|
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Clients</p>
|
||||||
<p className="text-2xl font-bold text-sky-300 mt-0.5">{clientCount}</p>
|
<p className="text-2xl font-bold text-sky-300 mt-0.5">{clientCount}</p>
|
||||||
<span className="text-[10px] text-blue-200">Organizations</span>
|
<span className="text-[10px] text-blue-200">Organizations</span>
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ export default function AdminWorkspace() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex flex-wrap items-center gap-3 sm:flex-nowrap sm:shrink-0">
|
||||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Registered Skills</p>
|
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Registered Skills</p>
|
||||||
<p className="text-2xl font-bold text-white mt-0.5">{skills.length}</p>
|
<p className="text-2xl font-bold text-white mt-0.5">{skills.length}</p>
|
||||||
<span className="text-[10px] text-emerald-300 font-medium">{activeSkills} Active</span>
|
<span className="text-[10px] text-emerald-300 font-medium">{activeSkills} Active</span>
|
||||||
@@ -92,7 +92,7 @@ export default function AdminWorkspace() {
|
|||||||
{/* Was "Training Paths". Replaced rather than dropped: the slot is
|
{/* Was "Training Paths". Replaced rather than dropped: the slot is
|
||||||
useful, and the split of agent skills across the two lists is
|
useful, and the split of agent skills across the two lists is
|
||||||
what this page actually governs. */}
|
what this page actually governs. */}
|
||||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Skill Kinds</p>
|
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Skill Kinds</p>
|
||||||
<p className="text-2xl font-bold text-sky-300 mt-0.5">{owliverCount + boardCount}</p>
|
<p className="text-2xl font-bold text-sky-300 mt-0.5">{owliverCount + boardCount}</p>
|
||||||
<span className="text-[10px] text-blue-200">{owliverCount} Owliver · {boardCount} Board</span>
|
<span className="text-[10px] text-blue-200">{owliverCount} Owliver · {boardCount} Board</span>
|
||||||
|
|||||||
@@ -197,7 +197,12 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
className="font-heading text-body-sm font-semibold text-ink-1 hover:text-krow-blue transition-colors text-left focus-visible:outline-none"
|
/* On a phone the identity column claims the width of the name
|
||||||
|
rather than stacking "Control Center Agent" three words tall,
|
||||||
|
which it can afford because the table already scrolls inside
|
||||||
|
its own container there. Released at `sm`, so every wider
|
||||||
|
layout distributes its columns exactly as it did before. */
|
||||||
|
className="whitespace-nowrap sm:whitespace-normal font-heading text-body-sm font-semibold text-ink-1 hover:text-krow-blue transition-colors text-left focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
{agent.name}
|
{agent.name}
|
||||||
</button>
|
</button>
|
||||||
@@ -493,7 +498,7 @@ export default function AdminWorkspaceAgents() {
|
|||||||
/* Clean Modern Directory Table View */
|
/* Clean Modern Directory Table View */
|
||||||
<div className="overflow-hidden rounded-2xl border border-border bg-surface shadow-xs">
|
<div className="overflow-hidden rounded-2xl border border-border bg-surface shadow-xs">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full min-w-[48rem] text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-subtle/60 text-[11px] font-semibold uppercase tracking-wider text-ink-4">
|
<tr className="border-b border-border bg-surface-subtle/60 text-[11px] font-semibold uppercase tracking-wider text-ink-4">
|
||||||
<th className="py-3 pl-4 pr-3 font-semibold">Agent</th>
|
<th className="py-3 pl-4 pr-3 font-semibold">Agent</th>
|
||||||
|
|||||||
@@ -29,5 +29,40 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
// Honor a port assigned by the environment; fall back to Vite's default.
|
// Honor a port assigned by the environment; fall back to Vite's default.
|
||||||
port: process.env.PORT ? Number(process.env.PORT) : 5173,
|
port: process.env.PORT ? Number(process.env.PORT) : 5173,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy the API so the browser only ever talks to one origin.
|
||||||
|
*
|
||||||
|
* This exists for the session cookie. The cookie is HttpOnly with
|
||||||
|
* SameSite=Lax, and a Lax cookie is not sent on a cross-site subresource
|
||||||
|
* request — which is exactly what `fetch('http://127.0.0.1:8080/...')` from
|
||||||
|
* a page served by `http://localhost:5173` is, because a browser treats
|
||||||
|
* those two as different sites. Every request after login would arrive
|
||||||
|
* without the cookie and be answered 401.
|
||||||
|
*
|
||||||
|
* The alternatives are both worse. SameSite=None would send the cookie on
|
||||||
|
* genuine cross-site requests too, which is the CSRF hole Lax closes, and
|
||||||
|
* it requires Secure — so it cannot work over plain HTTP on localhost at
|
||||||
|
* all. Widening CORS with credentials would keep the cross-site problem and
|
||||||
|
* add a second one.
|
||||||
|
*
|
||||||
|
* browser → localhost:5173/api/v1 → this proxy → 127.0.0.1:8080/api/v1
|
||||||
|
*
|
||||||
|
* Same origin from the browser's point of view, so the cookie is a
|
||||||
|
* first-party cookie, CORS never enters into it, and nothing in the React
|
||||||
|
* code names a backend host.
|
||||||
|
*
|
||||||
|
* Production serves the built assets and the API from one origin (see
|
||||||
|
* nginx.conf), so this is a development-only shim for a property the
|
||||||
|
* deployed app has for free.
|
||||||
|
*/
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:8080',
|
||||||
|
// The API does not route on Host, and rewriting it would make the
|
||||||
|
// Origin the backend sees disagree with the one the browser sent.
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user