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.*
|
||||
# ...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
|
||||
|
||||
@@ -3,16 +3,24 @@
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* 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 { DEMO_USER, seedData } from './seed';
|
||||
|
||||
initStore(seedData);
|
||||
import { DEMO_USER } from './seed';
|
||||
|
||||
const ENTITY_NAMES = [
|
||||
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
||||
@@ -33,9 +41,28 @@ const entities = Object.fromEntries(
|
||||
|
||||
/* ── 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';
|
||||
|
||||
function loadUser() {
|
||||
function readCachedUser() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
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
|
||||
* `QuotaExceededError` or a private-browsing refusal, and the caller was handed
|
||||
* a user object indistinguishable from a successful write. For preferences that
|
||||
* is invisible; for `customSkills`, which is where every account-authored skill
|
||||
* definition lives, it is the whole "I saved it and it was gone" report: the
|
||||
* toast said added, the list showed it, the reload did not.
|
||||
*
|
||||
* 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.
|
||||
* Failures are ignored, which is a real change from the old `persistUser` and a
|
||||
* safe one. That function checked its write and reported failure because
|
||||
* localStorage was the *only* copy — a swallowed `QuotaExceededError` was how
|
||||
* account-authored skills silently disappeared. Now the only copy is in
|
||||
* PostgreSQL, and a refused mirror costs one render of default preferences, not
|
||||
* data.
|
||||
*/
|
||||
function persistUser() {
|
||||
const payload = JSON.stringify(currentUser);
|
||||
function cacheUser() {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, payload);
|
||||
} catch (error) {
|
||||
return { persisted: false, error };
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(currentUser));
|
||||
} catch {
|
||||
// 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 = {
|
||||
/** 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() {
|
||||
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) {
|
||||
currentUser = { ...currentUser, ...patch };
|
||||
persistUser();
|
||||
return { ...currentUser };
|
||||
const user = await request('PATCH', '/me', { body: patch });
|
||||
currentUser = user;
|
||||
cacheUser();
|
||||
return { ...user };
|
||||
},
|
||||
|
||||
/**
|
||||
* Preferences, read synchronously.
|
||||
*
|
||||
* `me()` is async because the real client fetches, but the panel provider has
|
||||
* to decide whether Owliver starts open during its first render — one tick
|
||||
* later is a visible flash of the wrong workspace. The session user is already
|
||||
* 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.
|
||||
* A plain read of the same record `me()` returns, defaulted with the shape
|
||||
* from `seed.js` so a key the server has never stored still resolves. See the
|
||||
* note on `currentUser` for why this must not become async.
|
||||
*/
|
||||
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
|
||||
* alone. Preferences are where account-authored skills live, so "did this
|
||||
* survive the reload" is a question the caller has to be able to answer —
|
||||
* see `persistUser`.
|
||||
* `PATCH /me/preferences` shallow-merges and returns the whole merged object,
|
||||
* which is where `customSkills` and `customAgents` — every account-authored
|
||||
* definition — now live: `user_preferences.extra`, a real column in a real
|
||||
* 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) {
|
||||
currentUser = { ...currentUser, preferences: { ...auth.preferences(), ...patch } };
|
||||
const write = persistUser();
|
||||
return { user: { ...currentUser }, ...write };
|
||||
},
|
||||
|
||||
isAuthenticated() {
|
||||
return true;
|
||||
const preferences = await request('PATCH', '/me/preferences', { body: patch });
|
||||
currentUser = { ...currentUser, preferences };
|
||||
cacheUser();
|
||||
return { user: { ...currentUser }, persisted: true, error: null };
|
||||
},
|
||||
|
||||
/**
|
||||
* There is no identity provider to sign out of, so this clears the local
|
||||
* session and returns to the requested page.
|
||||
* Whether the last `GET /me` succeeded.
|
||||
*
|
||||
* 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 {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
await request('POST', '/auth/logout');
|
||||
} 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 };
|
||||
window.location.href = typeof redirectTo === 'string' ? redirectTo : '/';
|
||||
authenticated = false;
|
||||
forgetUser();
|
||||
window.location.href = typeof redirectTo === 'string' ? redirectTo : '/admin/login';
|
||||
},
|
||||
|
||||
redirectToLogin() {
|
||||
window.location.href = '/';
|
||||
window.location.href = '/admin/login';
|
||||
},
|
||||
};
|
||||
|
||||
@@ -158,8 +294,27 @@ const 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() {
|
||||
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();
|
||||
}
|
||||
|
||||
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 { Outlet } from 'react-router-dom';
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
|
||||
|
||||
@@ -9,8 +9,24 @@ const DefaultFallback = () => (
|
||||
</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 }) {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
||||
const signedOut = unauthenticatedElement ?? (
|
||||
<Navigate to="/admin/login" replace state={{ from: location.pathname }} />
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked && !isLoadingAuth) {
|
||||
@@ -26,11 +42,11 @@ export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthe
|
||||
if (authError.type === 'user_not_registered') {
|
||||
return <UserNotRegisteredError />;
|
||||
}
|
||||
return unauthenticatedElement;
|
||||
return signedOut;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return unauthenticatedElement;
|
||||
return signedOut;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
|
||||
@@ -23,12 +23,18 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
|
||||
<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">
|
||||
<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>
|
||||
{/* A count of whatever the page is a list of, stated beside its
|
||||
name rather than only inside the list. */}
|
||||
{meta && <span className="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">
|
||||
{meta && <span className="whitespace-nowrap text-body-sm tabular-nums text-ink-4">{meta}</span>}
|
||||
<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" />
|
||||
Live System
|
||||
</span>
|
||||
@@ -55,7 +61,10 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
|
||||
export function SectionTitle({ id, title, meta, action, className }) {
|
||||
return (
|
||||
<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>
|
||||
{meta && <span className="text-caption text-ink-4">{meta}</span>}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils';
|
||||
import KrowAssistant from './KrowAssistant';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
import { ResizeDivider } from './ResizeDivider';
|
||||
import { useIsPhone, useViewportWidth, useVisualViewport } from './viewport';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
const EXPANDED_WIDTH = 620;
|
||||
@@ -18,32 +19,6 @@ const LEAD_PADDING = 8;
|
||||
const GUTTER = HANDLE_WIDTH + LEAD_PADDING;
|
||||
/** Expanded must never dominate: the dashboard stays the primary experience. */
|
||||
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.
|
||||
*
|
||||
@@ -64,7 +39,12 @@ function CollapsedTrigger({ page, onRestore }) {
|
||||
onClick={onRestore}
|
||||
aria-label={`Show the Owliver workspace for ${page}`}
|
||||
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
|
||||
hover:border-krow-blue/40 hover:shadow-lg
|
||||
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 layout rather than an overlay: the dashboard reflows beside it instead of
|
||||
* being covered, and collapsing restores the original layout exactly.
|
||||
* The same `KrowAssistant`, the same context, the same runtime, the same
|
||||
* conversation. What changes is only where it is mounted: on a phone the
|
||||
* 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:
|
||||
*
|
||||
@@ -111,10 +185,24 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
||||
setWidth, resetWidth, open, close, expand, restore,
|
||||
} = useAssistantPanel();
|
||||
const viewportWidth = useViewportWidth();
|
||||
const isPhone = useIsPhone();
|
||||
|
||||
// No assistant on this route: no column, no rail, no trace in the layout.
|
||||
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.
|
||||
Both are clamped against the viewport so the dashboard is never squeezed. */
|
||||
const viewportCap = Math.round(viewportWidth * MAX_VIEWPORT_SHARE);
|
||||
@@ -131,25 +219,6 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
||||
|
||||
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 (
|
||||
<aside
|
||||
aria-label="Owliver workspace"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { resolveAssistantContext } from './placement';
|
||||
import { PHONE_QUERY } from './viewport';
|
||||
import { PageContextProvider } from './PageContext';
|
||||
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 }) {
|
||||
/* Two different things, deliberately kept apart: the account preference is
|
||||
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
|
||||
an account with Owliver off does not get the panel pushed back at it on
|
||||
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(
|
||||
() => 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 [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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -194,7 +194,7 @@ export function DataTable({
|
||||
<LoadingOverlay show={refreshing} />
|
||||
|
||||
<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>}
|
||||
|
||||
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>
|
||||
|
||||
@@ -83,7 +83,14 @@ export function MetricStrip({ items = [], columns, className, loading = false })
|
||||
<div>
|
||||
{/* Header: Label & Icon */}
|
||||
<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}
|
||||
</p>
|
||||
{IconComponent && (
|
||||
|
||||
@@ -43,7 +43,11 @@ export function Modal({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
|
||||
<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}
|
||||
onEscapeKeyDown={busy ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function ActivityLogTable({ activities }) {
|
||||
</div>
|
||||
</div>
|
||||
<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]">
|
||||
<tr>
|
||||
<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 (
|
||||
<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
|
||||
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)}
|
||||
>
|
||||
{/* Rank */}
|
||||
@@ -149,7 +157,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=200&h=200&fit=crop&crop=faces`}
|
||||
alt={application.applicant_name}
|
||||
@@ -158,8 +166,10 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Info. `basis-40` rather than the flex default of 0 so the name has a
|
||||
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">
|
||||
<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>
|
||||
@@ -201,76 +211,84 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
{status !== 'applied' && <ScoreRing score={score} />}
|
||||
{/* Score and actions.
|
||||
`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 */}
|
||||
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{/* The full record, at the route that already serves it.
|
||||
Clicking the row expands a summary in place, which is the right
|
||||
default while comparing people — but the complete profile had no
|
||||
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
|
||||
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 && (
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{/* The full record, at the route that already serves it.
|
||||
Clicking the row expands a summary in place, which is the right
|
||||
default while comparing people — but the complete profile had no
|
||||
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
|
||||
same person. One link, one route, one profile. */}
|
||||
<button
|
||||
onClick={() => onAction(application)}
|
||||
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
|
||||
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"
|
||||
>
|
||||
{status === 'interview' ? 'AI Interview' : 'Interview'}
|
||||
View profile
|
||||
<ArrowRight className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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">
|
||||
Actions
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
|
||||
{onAction && canTakeAction && (
|
||||
<button
|
||||
onClick={() => onAction(application)}
|
||||
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
|
||||
>
|
||||
{status === 'interview' ? 'AI Interview' : 'Interview'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
|
||||
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
||||
<span className="text-[#374151] text-[13px] font-medium">Message</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669] text-[13px] font-medium">Call</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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">
|
||||
Actions
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
|
||||
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
||||
<span className="text-[#374151] text-[13px] font-medium">Message</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={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669] text-[13px] font-medium">Call</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>
|
||||
<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 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>
|
||||
{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]';
|
||||
return (
|
||||
<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">
|
||||
<DialogTitle>{profile.full_name} — KROW Identity</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -164,7 +164,7 @@ function TableSection({ data }) {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{columns.map((c) => (
|
||||
|
||||
@@ -33,7 +33,7 @@ const DialogContent = React.forwardRef(({ className, children, ...props }, ref)
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}>
|
||||
|
||||
@@ -195,6 +195,43 @@
|
||||
|
||||
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
|
||||
tint at once — which is exactly why the canvas is one layer and not a
|
||||
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 */}
|
||||
<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">
|
||||
@@ -323,9 +327,20 @@ export default function AdminLayout() {
|
||||
</SheetContent>
|
||||
</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">
|
||||
<main className="min-w-0 flex-1 py-6">
|
||||
<main className="min-w-0 flex-1 pb-24 pt-6 md:py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
|
||||
@@ -4,11 +4,17 @@ import { base44 } from '@/api/base44Client';
|
||||
/**
|
||||
* Auth context.
|
||||
*
|
||||
* The reference app resolves an identity provider before rendering. The demo
|
||||
* has no provider, so it resolves the seeded user immediately — but it keeps
|
||||
* the same context shape (`isLoadingAuth`, `isLoadingPublicSettings`,
|
||||
* `authError`, `navigateToLogin`, …) so `App.jsx`, `ProtectedRoute` and every
|
||||
* consumer stay unchanged.
|
||||
* The single source of truth for "is anyone signed in", and it is not a flag
|
||||
* this app sets. `checkUserAuth` calls `GET /me`; the server reads its own
|
||||
* session table and either returns a user or answers 401. Success means there
|
||||
* is a session, failure means there is not, and there is no third answer this
|
||||
* 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));
|
||||
@@ -27,6 +33,10 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(currentUser);
|
||||
setIsAuthenticated(true);
|
||||
} 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);
|
||||
} finally {
|
||||
setIsLoadingAuth(false);
|
||||
@@ -38,10 +48,27 @@ export const AuthProvider = ({ children }) => {
|
||||
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);
|
||||
setIsAuthenticated(false);
|
||||
base44.auth.logout(shouldRedirect ? '/' : undefined);
|
||||
// Revokes the session server-side, expires the cookie, then redirects.
|
||||
await base44.auth.logout(redirectTo);
|
||||
};
|
||||
|
||||
const navigateToLogin = () => {
|
||||
@@ -59,6 +86,7 @@ export const AuthProvider = ({ children }) => {
|
||||
authError: null,
|
||||
appPublicSettings: null,
|
||||
authChecked,
|
||||
login,
|
||||
logout,
|
||||
navigateToLogin,
|
||||
checkUserAuth,
|
||||
|
||||
@@ -1,50 +1,49 @@
|
||||
/**
|
||||
* Admin sign-in state.
|
||||
*
|
||||
* The demo's data client (`base44.auth`) reports the seeded user as permanently
|
||||
* signed in — there is no identity provider behind it. That is fine for the
|
||||
* Employer and Talent demos, but the Admin console is supposed to be reached
|
||||
* through its own sign-in, and "always authenticated" would mean /admin opened
|
||||
* straight into the Control Center with the login page never seen.
|
||||
* This file used to BE the Admin session: a `sessionStorage` flag, set by the
|
||||
* login page and read by the route guard, standing in for a real one. The
|
||||
* comment on it said so plainly — "it is not security — a browser flag never
|
||||
* is — it is the state a real session would occupy … so swapping in a real
|
||||
* provider later means changing this file and nothing else."
|
||||
*
|
||||
* So Admin keeps a session marker of its own. It is deliberately thin: a single
|
||||
* flag, set by the login page and read by the route guard. It is not security —
|
||||
* a browser flag never is — it is the state a real session would occupy, put in
|
||||
* the one place that both the guard and the login screen can agree on, so the
|
||||
* flow is right now and swapping in a real provider later means changing this
|
||||
* file and nothing else.
|
||||
* That swap has happened. The session is now a row in PostgreSQL, addressed by
|
||||
* an HttpOnly cookie this code cannot read, and the question "is anyone signed
|
||||
* in" is answered by `GET /me` through `AuthContext` — see `AdminRoute`, which
|
||||
* now asks the context instead of asking here.
|
||||
*
|
||||
* `sessionStorage`, not `localStorage`: closing the tab should end the session,
|
||||
* which is both the more defensible default for an administrative console and
|
||||
* what makes the login page the app's actual starting point.
|
||||
* The three functions survive because `AdminLayout` and the Admin `Profile`
|
||||
* page call `endAdminSession()` on their sign-out buttons, and those files are
|
||||
* 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';
|
||||
|
||||
/** True when this tab has signed in to the Admin console. */
|
||||
/**
|
||||
* Whether 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() {
|
||||
try {
|
||||
return sessionStorage.getItem(SESSION_KEY) === '1';
|
||||
} catch {
|
||||
// Private mode with storage denied: fail closed, so the login page shows.
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Records a successful sign-in. */
|
||||
export function startAdminSession() {
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_KEY, '1');
|
||||
} catch {
|
||||
// Nothing to do — the guard will simply ask for sign-in again.
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Records a successful sign-in.
|
||||
*
|
||||
* @deprecated A no-op. Signing in is `POST /api/v1/auth/login`, and what it
|
||||
* produces is an HttpOnly cookie, not a value in this module.
|
||||
*/
|
||||
export function startAdminSession() {}
|
||||
|
||||
/** Clears the session. Used by sign-out. */
|
||||
export function endAdminSession() {
|
||||
try {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Clears the session.
|
||||
*
|
||||
* @deprecated A no-op, kept because the sign-out buttons in `AdminLayout` and
|
||||
* the Admin `Profile` page still call it before `base44.auth.logout()`. The
|
||||
* 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. */
|
||||
duration_months: record.duration_months === '' || record.duration_months == 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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Sparkles, ChevronDown, ChevronUp, Sliders, Loader2, Star } from 'lucide
|
||||
import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useJobPosting, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
|
||||
import { toast } from 'react-hot-toast';
|
||||
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 { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
@@ -130,11 +130,11 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
// Create draft if not exists
|
||||
let id = draftId;
|
||||
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;
|
||||
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);
|
||||
toast.success('Job description generated!');
|
||||
} catch {
|
||||
@@ -153,11 +153,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
}
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
status,
|
||||
company: String(form.company || '').trim(),
|
||||
pay_range_min: Number(form.pay_range_min) || 0,
|
||||
pay_range_max: Number(form.pay_range_max) || 0,
|
||||
...toPositionPayload(form, { status }),
|
||||
...(aiResult ? { description: aiResult.description, responsibilities: aiResult.responsibilities, qualifications: aiResult.qualifications, nice_to_haves: aiResult.nice_to_haves, ai_generated: true } : {}),
|
||||
};
|
||||
let createdId = draftId;
|
||||
@@ -190,12 +186,15 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
<div className={embedded ? '' : 'max-w-3xl mx-auto'}>
|
||||
{/* Header */}
|
||||
{!embedded && (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { hasAdminSession } from '@/lib/admin/session';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
|
||||
/**
|
||||
* Gate on the Admin console.
|
||||
*
|
||||
* Without this, `/admin` renders the Control Center for anyone who types the URL,
|
||||
* because the demo's data client reports the seeded user as permanently signed in
|
||||
* (see session.js). The console is meant to be entered through its own sign-in,
|
||||
* so the guard sends an unauthenticated visitor to the login page.
|
||||
* It asks the auth context, which reflects `GET /me` — a real question to the
|
||||
* server about a real session — rather than the `sessionStorage` flag it used
|
||||
* to read. That flag could be set from the browser console; a session row keyed
|
||||
* 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
|
||||
* 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() {
|
||||
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 }} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ export default function AdminAnalytics() {
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
|
||||
<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>
|
||||
<thead>
|
||||
<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`} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
<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">
|
||||
Applicant supply against candidates clearing the bar, per open role
|
||||
</caption>
|
||||
|
||||
@@ -922,7 +922,7 @@ export default function ControlCenter() {
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-surface">
|
||||
<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>
|
||||
<thead>
|
||||
<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 { KROW_LOGO_URL } from '@/assets/brand';
|
||||
import { Checkbox } from '@/components/ds';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { startAdminSession } from '@/lib/admin/session';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login } = useAuth();
|
||||
|
||||
const from = location.state?.from;
|
||||
const returnTo = typeof from === 'string' && from.startsWith('/admin') && from !== '/admin/login'
|
||||
@@ -28,8 +41,11 @@ export default function AdminLogin() {
|
||||
const next = {};
|
||||
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';
|
||||
// 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';
|
||||
else if (form.password.length < 8) next.password = 'Passwords are at least 8 characters';
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
@@ -40,17 +56,26 @@ export default function AdminLogin() {
|
||||
if (!validate()) return;
|
||||
|
||||
setStatus('loading');
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
|
||||
if (form.password.toLowerCase() === 'wrong') {
|
||||
try {
|
||||
// The session is created server-side and arrives as an HttpOnly cookie.
|
||||
// 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');
|
||||
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;
|
||||
}
|
||||
|
||||
await base44.auth.updateMe({ account_type: 'admin' });
|
||||
localStorage.setItem('activeRole', 'admin');
|
||||
startAdminSession();
|
||||
setStatus('success');
|
||||
setTimeout(() => navigate(returnTo, { replace: true }), 320);
|
||||
};
|
||||
@@ -192,7 +217,7 @@ export default function AdminLogin() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Forgot password?
|
||||
@@ -213,7 +238,8 @@ export default function AdminLogin() {
|
||||
</form>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,8 +91,10 @@ export default function AdminProfile() {
|
||||
<AdminPage
|
||||
title="Admin Profile"
|
||||
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={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
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"
|
||||
@@ -173,13 +175,13 @@ export default function AdminProfile() {
|
||||
</div>
|
||||
|
||||
{/* 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="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||
<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="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-2xl font-bold text-white mt-0.5">{PERMISSIONS.length}</p>
|
||||
<span className="text-[10px] text-blue-200">Full Control</span>
|
||||
</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-2xl font-bold text-emerald-300 mt-0.5">Tier 1</p>
|
||||
<span className="text-[10px] text-blue-200">Max Privileges</span>
|
||||
|
||||
@@ -55,18 +55,18 @@ export default function AdminSettings() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 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 flex-wrap items-center gap-3 sm:flex-nowrap sm:shrink-0">
|
||||
<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-2xl font-bold text-white mt-0.5">{staff.length}</p>
|
||||
<span className="text-[10px] text-blue-200">On Platform</span>
|
||||
</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-2xl font-bold text-emerald-300 mt-0.5">{postings.length}</p>
|
||||
<span className="text-[10px] text-blue-200">Active Roles</span>
|
||||
</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-2xl font-bold text-sky-300 mt-0.5">{clientCount}</p>
|
||||
<span className="text-[10px] text-blue-200">Organizations</span>
|
||||
|
||||
@@ -83,8 +83,8 @@ export default function AdminWorkspace() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 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 flex-wrap items-center gap-3 sm:flex-nowrap sm:shrink-0">
|
||||
<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-2xl font-bold text-white mt-0.5">{skills.length}</p>
|
||||
<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
|
||||
useful, and the split of agent skills across the two lists is
|
||||
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-2xl font-bold text-sky-300 mt-0.5">{owliverCount + boardCount}</p>
|
||||
<span className="text-[10px] text-blue-200">{owliverCount} Owliver · {boardCount} Board</span>
|
||||
|
||||
@@ -197,7 +197,12 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
</button>
|
||||
@@ -493,7 +498,7 @@ export default function AdminWorkspaceAgents() {
|
||||
/* Clean Modern Directory Table View */
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-surface shadow-xs">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -29,5 +29,40 @@ export default defineConfig({
|
||||
server: {
|
||||
// Honor a port assigned by the environment; fall back to Vite's default.
|
||||
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