Merge remote-tracking branch 'gitea/main' into feat/revenue-intelligence
CI / verify (push) Successful in 2m54s
CI / verify (push) Successful in 2m54s
This commit is contained in:
@@ -53,6 +53,7 @@ test('invite-bound member creates, sells and observes capacity through authentic
|
||||
PIGGY_ENABLED: 'false',
|
||||
});
|
||||
const authProvider = {
|
||||
name: 'e2e-stub',
|
||||
async verifyAccessToken(token: string) {
|
||||
if (token !== accessToken) throw new Error('Invalid E2E token.');
|
||||
return { subject, email };
|
||||
|
||||
@@ -4,8 +4,22 @@
|
||||
* Providers prove an external identity. They do not decide whether that
|
||||
* identity belongs to PIG; workspace membership remains a database decision
|
||||
* in the authenticator and signup route.
|
||||
*
|
||||
* Two implementations:
|
||||
*
|
||||
* **Supabase** — the hosted deployment. Well-known JWKS path, fixed issuer.
|
||||
* **OIDC** — any standards-compliant identity provider, which is what an
|
||||
* on-premises install needs. The customer already runs Okta,
|
||||
* Entra, Keycloak, Auth0, Authentik or Google Workspace behind
|
||||
* their VPN; asking them to stand up a second identity system
|
||||
* to use PIG would be a serious adoption tax, and in a
|
||||
* regulated environment often simply refused.
|
||||
*
|
||||
* Both reduce to the same thing — verify a bearer token, return a stable
|
||||
* subject and an email — because that is all PIG needs. Everything downstream
|
||||
* (teams, roles, capabilities) is PIG's own data keyed on that subject.
|
||||
*/
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
|
||||
import type { Config } from './config';
|
||||
|
||||
export interface VerifiedIdentity {
|
||||
@@ -14,6 +28,8 @@ export interface VerifiedIdentity {
|
||||
}
|
||||
|
||||
export interface AuthProvider {
|
||||
/** Human-readable, for startup logging and the health surface. */
|
||||
readonly name: string;
|
||||
verifyAccessToken(token: string): Promise<VerifiedIdentity>;
|
||||
}
|
||||
|
||||
@@ -24,6 +40,7 @@ export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider {
|
||||
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
|
||||
|
||||
return {
|
||||
name: 'supabase',
|
||||
async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
|
||||
const { payload } = await jwtVerify(token, jwks, { issuer });
|
||||
if (!payload.sub) throw new Error('token has no subject');
|
||||
@@ -36,8 +53,146 @@ export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider {
|
||||
};
|
||||
}
|
||||
|
||||
export interface OidcProviderOptions {
|
||||
/** The `iss` value the provider stamps into its tokens. */
|
||||
issuer: string;
|
||||
/**
|
||||
* JWKS location. Optional: when omitted it is discovered from
|
||||
* `${issuer}/.well-known/openid-configuration`, which every compliant
|
||||
* provider serves. Setting it explicitly avoids one startup fetch and lets
|
||||
* an air-gapped deployment skip discovery entirely.
|
||||
*/
|
||||
jwksUri?: string;
|
||||
/**
|
||||
* Expected audience. **Strongly recommended.**
|
||||
*
|
||||
* Without it, any token the identity provider issued for *any* application
|
||||
* in the same tenant will verify here — a token minted for an unrelated
|
||||
* internal tool would be accepted as a PIG session. `jose` only checks the
|
||||
* audience when asked to, so leaving this unset is a real hole rather than a
|
||||
* relaxed default, and it is warned about at boot.
|
||||
*/
|
||||
audience?: string;
|
||||
/**
|
||||
* Claim to read the email from. Providers disagree: most use `email`, some
|
||||
* corporate Entra configurations use `preferred_username` or `upn`. Each
|
||||
* candidate is tried in order.
|
||||
*/
|
||||
emailClaims?: string[];
|
||||
/** Tolerance for clock skew between PIG and the provider. */
|
||||
clockToleranceSeconds?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_EMAIL_CLAIMS = ['email', 'preferred_username', 'upn'];
|
||||
|
||||
export function createOidcAuthProvider(options: OidcProviderOptions): AuthProvider {
|
||||
const issuer = options.issuer.replace(/\/+$/, '');
|
||||
const emailClaims = options.emailClaims?.length ? options.emailClaims : DEFAULT_EMAIL_CLAIMS;
|
||||
|
||||
/*
|
||||
* Resolved once, lazily, and cached — including the failure.
|
||||
*
|
||||
* Discovery is a network call, so doing it per request would put the
|
||||
* identity provider on the critical path of every API call. Doing it eagerly
|
||||
* at boot would mean PIG refuses to start if the provider is briefly
|
||||
* unreachable, which on a customer's own network is a bad trade: their
|
||||
* identity provider rebooting should not take the CRM down with it.
|
||||
*
|
||||
* So it happens on first use and is retried on the next request if it fails.
|
||||
*/
|
||||
let jwksPromise: Promise<ReturnType<typeof createRemoteJWKSet>> | null = null;
|
||||
|
||||
async function resolveJwks() {
|
||||
if (options.jwksUri) return createRemoteJWKSet(new URL(options.jwksUri));
|
||||
|
||||
const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
|
||||
const response = await fetch(discoveryUrl, { headers: { accept: 'application/json' } });
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`OIDC discovery failed: ${discoveryUrl} returned ${response.status}. ` +
|
||||
'Set PIG_OIDC_JWKS_URI to skip discovery.',
|
||||
);
|
||||
}
|
||||
|
||||
const document = (await response.json()) as { jwks_uri?: string; issuer?: string };
|
||||
if (!document.jwks_uri) {
|
||||
throw new Error(`OIDC discovery document at ${discoveryUrl} has no jwks_uri.`);
|
||||
}
|
||||
|
||||
// A discovery document whose issuer disagrees with the configured one means
|
||||
// the deployment is pointed somewhere unexpected. Verification would fail
|
||||
// later anyway; failing here says why.
|
||||
if (document.issuer && document.issuer.replace(/\/+$/, '') !== issuer) {
|
||||
throw new Error(
|
||||
`OIDC issuer mismatch: configured ${issuer}, discovery reports ${document.issuer}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return createRemoteJWKSet(new URL(document.jwks_uri));
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'oidc',
|
||||
async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
|
||||
if (!jwksPromise) {
|
||||
jwksPromise = resolveJwks().catch((error) => {
|
||||
// Clear the cache so the next request retries rather than being
|
||||
// stuck with a rejected promise for the process lifetime.
|
||||
jwksPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
const jwks = await jwksPromise;
|
||||
const { payload } = await jwtVerify(token, jwks, {
|
||||
issuer,
|
||||
...(options.audience ? { audience: options.audience } : {}),
|
||||
clockTolerance: options.clockToleranceSeconds ?? 5,
|
||||
});
|
||||
|
||||
if (!payload.sub) throw new Error('token has no subject');
|
||||
|
||||
return { subject: payload.sub, email: readEmail(payload, emailClaims) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readEmail(payload: JWTPayload, claims: string[]): string | undefined {
|
||||
for (const claim of claims) {
|
||||
const value = payload[claim];
|
||||
// A `preferred_username` is not always an address; only take it if it
|
||||
// looks like one, so a bare username never becomes an account identity.
|
||||
if (typeof value === 'string' && value.includes('@')) return value.toLowerCase();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the provider this deployment is configured for.
|
||||
*
|
||||
* OIDC wins when both are set, so an on-premises install can keep the Supabase
|
||||
* values in its environment file without them quietly taking precedence.
|
||||
*/
|
||||
export function createConfiguredAuthProvider(
|
||||
config: Pick<Config, 'SUPABASE_URL'>,
|
||||
config: Pick<
|
||||
Config,
|
||||
| 'SUPABASE_URL'
|
||||
| 'PIG_OIDC_ISSUER'
|
||||
| 'PIG_OIDC_JWKS_URI'
|
||||
| 'PIG_OIDC_AUDIENCE'
|
||||
| 'PIG_OIDC_EMAIL_CLAIMS'
|
||||
>,
|
||||
): AuthProvider | null {
|
||||
if (config.PIG_OIDC_ISSUER) {
|
||||
return createOidcAuthProvider({
|
||||
issuer: config.PIG_OIDC_ISSUER,
|
||||
jwksUri: config.PIG_OIDC_JWKS_URI || undefined,
|
||||
audience: config.PIG_OIDC_AUDIENCE || undefined,
|
||||
emailClaims: config.PIG_OIDC_EMAIL_CLAIMS
|
||||
? config.PIG_OIDC_EMAIL_CLAIMS.split(',').map((claim) => claim.trim()).filter(Boolean)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return config.SUPABASE_URL ? createSupabaseAuthProvider(config.SUPABASE_URL) : null;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,21 @@ const schema = z.object({
|
||||
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'),
|
||||
|
||||
SUPABASE_URL: z.string().url().optional(),
|
||||
|
||||
/*
|
||||
* OIDC — the on-premises path.
|
||||
*
|
||||
* A customer running PIG inside their own network already has an identity
|
||||
* provider. Setting PIG_OIDC_ISSUER switches authentication to it and takes
|
||||
* precedence over any Supabase values left in the environment file.
|
||||
*/
|
||||
PIG_OIDC_ISSUER: z.string().url().optional(),
|
||||
/** Optional. Discovered from the issuer when omitted. */
|
||||
PIG_OIDC_JWKS_URI: z.string().url().optional(),
|
||||
/** Strongly recommended — see the boot warning. */
|
||||
PIG_OIDC_AUDIENCE: z.string().optional(),
|
||||
/** Comma-separated, in preference order. Defaults cover most providers. */
|
||||
PIG_OIDC_EMAIL_CLAIMS: z.string().optional(),
|
||||
SUPABASE_ANON_KEY: z.string().optional(),
|
||||
SUPABASE_SERVICE_KEY: z.string().optional(),
|
||||
|
||||
@@ -203,17 +218,48 @@ function warnOnFootguns(config: Config): void {
|
||||
warn('PIG_ADMIN_EMAILS is empty — no user will have platform-admin rights.');
|
||||
}
|
||||
|
||||
if (!config.SUPABASE_URL) {
|
||||
// Keyed on BOTH providers, not just Supabase. An OIDC deployment has
|
||||
// authentication and this warning previously claimed it did not — which is
|
||||
// worse than saying nothing, because an operator reading "authentication is
|
||||
// DISABLED" on a correctly secured install learns to ignore the warnings.
|
||||
if (!config.SUPABASE_URL && !config.PIG_OIDC_ISSUER) {
|
||||
warn(
|
||||
'SUPABASE_URL is not set — authentication is DISABLED and every request ' +
|
||||
'runs as the development user. Never do this in production.',
|
||||
'No identity provider is configured (SUPABASE_URL or PIG_OIDC_ISSUER) — ' +
|
||||
'authentication is DISABLED and every request runs as the development ' +
|
||||
'user. Never do this in production.',
|
||||
);
|
||||
}
|
||||
|
||||
if (config.isProduction && !config.SUPABASE_URL) {
|
||||
if (config.PIG_OIDC_ISSUER && config.SUPABASE_URL) {
|
||||
warn(
|
||||
'Both PIG_OIDC_ISSUER and SUPABASE_URL are set — OIDC takes precedence and ' +
|
||||
'Supabase will not be used for authentication.',
|
||||
);
|
||||
}
|
||||
|
||||
if (config.PIG_OIDC_ISSUER && !config.PIG_OIDC_AUDIENCE) {
|
||||
// Not fatal, because some providers issue single-audience tokens where it
|
||||
// adds nothing — but on a shared corporate tenant this is the difference
|
||||
// between "a token for PIG" and "a token for anything in the company".
|
||||
warn(
|
||||
'PIG_OIDC_AUDIENCE is not set. Any token your identity provider issued for ' +
|
||||
'ANY application in the same tenant will be accepted here. Set it unless ' +
|
||||
'you are certain that is safe.',
|
||||
);
|
||||
}
|
||||
|
||||
if (config.PIG_OIDC_ISSUER && config.SUPABASE_SERVICE_KEY) {
|
||||
warn(
|
||||
'SUPABASE_SERVICE_KEY is set while running on OIDC. Self-registration mints ' +
|
||||
'Supabase accounts, which an OIDC deployment does not use — unset it and ' +
|
||||
'provision users through your identity provider instead.',
|
||||
);
|
||||
}
|
||||
|
||||
if (config.isProduction && !config.SUPABASE_URL && !config.PIG_OIDC_ISSUER) {
|
||||
throw new Error(
|
||||
'Refusing to start: NODE_ENV=production with no SUPABASE_URL would serve ' +
|
||||
'the entire CRM unauthenticated.',
|
||||
'Refusing to start: NODE_ENV=production with neither SUPABASE_URL nor ' +
|
||||
'PIG_OIDC_ISSUER would serve the entire CRM unauthenticated.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+19
-3
@@ -57,9 +57,25 @@ if (existsSync(webDist)) {
|
||||
return serveStatic({ root: './apps/web/dist' })(c, next);
|
||||
});
|
||||
|
||||
// Client-side routes (/margin, /capacity, …) have no file on disk and must
|
||||
// receive the shell so the router can take over.
|
||||
app.get('*', serveStatic({ path: './apps/web/dist/index.html' }));
|
||||
/*
|
||||
* Client-side routes (/margin, /capacity, …) have no file on disk and must
|
||||
* receive the shell so the router can take over.
|
||||
*
|
||||
* The `/api/` guard is repeated here deliberately. Without it an unknown API
|
||||
* path — a typo, a renamed endpoint, an older client — falls through to this
|
||||
* fallback and returns **HTTP 200 with the SPA's HTML**. That is close to the
|
||||
* worst possible failure for an API consumer: `response.ok` is true, so
|
||||
* nothing treats it as an error, and the caller then fails on `JSON.parse`
|
||||
* with "Unexpected token '<'" a long way from the actual cause. The MCP
|
||||
* server, the CLI and Piggy all consume this API and would all have hit it.
|
||||
*
|
||||
* Confirmed against production before fixing: an authenticated GET to
|
||||
* /api/keys (the real path is /api/api-keys) returned 200 text/html.
|
||||
*/
|
||||
app.get('*', async (c, next) => {
|
||||
if (new URL(c.req.url).pathname.startsWith('/api/')) return next();
|
||||
return serveStatic({ path: './apps/web/dist/index.html' })(c, next);
|
||||
});
|
||||
console.log('[pig] serving front end from', webDist);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,101 +1,214 @@
|
||||
/**
|
||||
* Tests for the identity-provider boundary.
|
||||
* Tests for the OIDC authentication provider.
|
||||
*
|
||||
* These cases pin the trust decisions shared by protected requests and profile
|
||||
* creation. Membership remains deliberately outside this module.
|
||||
* This is the code that decides whether a stranger is who they claim to be, so
|
||||
* the cases below are mostly about what it must **refuse**. A provider that
|
||||
* accepts a token it should not is not a bug you find in staging.
|
||||
*
|
||||
* Keys are generated per test and the JWKS is served from a local HTTP server,
|
||||
* so these run offline and deterministically — no network, no fixtures that
|
||||
* expire.
|
||||
*/
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import { exportJWK, SignJWT } from 'jose';
|
||||
import {
|
||||
createSupabaseAuthProvider,
|
||||
type AuthProvider,
|
||||
} from '../src/lib/auth-provider';
|
||||
import { SignJWT, exportJWK, generateKeyPair, type JWK } from 'jose';
|
||||
import { createOidcAuthProvider, createConfiguredAuthProvider } from '../src/lib/auth-provider';
|
||||
|
||||
describe('Supabase auth provider', () => {
|
||||
let server: Server;
|
||||
let provider: AuthProvider;
|
||||
let issuer: string;
|
||||
let privateKey: KeyObject;
|
||||
let server: Server;
|
||||
let origin: string;
|
||||
// Derived from jose rather than referencing the DOM `CryptoKey` type, which
|
||||
// is not in this package's type lib.
|
||||
type SigningKey = Awaited<ReturnType<typeof generateKeyPair>>['privateKey'];
|
||||
|
||||
before(async () => {
|
||||
const keys = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
privateKey = keys.privateKey;
|
||||
const publicJwk = await exportJWK(keys.publicKey);
|
||||
let privateKey: SigningKey;
|
||||
let otherPrivateKey: SigningKey;
|
||||
let jwks: { keys: JWK[] };
|
||||
/** Flipped per test to exercise discovery failures. */
|
||||
let discoveryStatus = 200;
|
||||
let serveDiscovery = true;
|
||||
|
||||
server = createServer((request, response) => {
|
||||
if (request.url !== '/auth/v1/.well-known/jwks.json') {
|
||||
response.writeHead(404).end();
|
||||
before(async () => {
|
||||
const pair = await generateKeyPair('RS256');
|
||||
const other = await generateKeyPair('RS256');
|
||||
privateKey = pair.privateKey;
|
||||
otherPrivateKey = other.privateKey;
|
||||
|
||||
const publicJwk = await exportJWK(pair.publicKey);
|
||||
publicJwk.kid = 'test-key';
|
||||
publicJwk.alg = 'RS256';
|
||||
jwks = { keys: [publicJwk] };
|
||||
|
||||
server = createServer((req, res) => {
|
||||
if (req.url === '/.well-known/openid-configuration') {
|
||||
if (!serveDiscovery || discoveryStatus !== 200) {
|
||||
res.writeHead(discoveryStatus === 200 ? 404 : discoveryStatus);
|
||||
res.end('{}');
|
||||
return;
|
||||
}
|
||||
response.setHeader('content-type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
keys: [{ ...publicJwk, alg: 'RS256', kid: 'test-key', use: 'sig' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
const address = server.address() as AddressInfo;
|
||||
const supabaseUrl = `http://127.0.0.1:${address.port}`;
|
||||
issuer = `${supabaseUrl}/auth/v1`;
|
||||
provider = createSupabaseAuthProvider(supabaseUrl);
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ issuer: origin, jwks_uri: `${origin}/jwks` }));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/jwks') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(jwks));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
after(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
);
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
async function sign(claims: Record<string, unknown>, tokenIssuer = issuer): Promise<string> {
|
||||
return new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||||
.setIssuer(tokenIssuer)
|
||||
.setExpirationTime('5m')
|
||||
.sign(privateKey);
|
||||
}
|
||||
after(() => server?.close());
|
||||
|
||||
it('returns only the verified external identity claims', async () => {
|
||||
const token = await sign({ sub: 'provider-user-1', email: 'Owner@Example.com' });
|
||||
async function mint(
|
||||
claims: Record<string, unknown>,
|
||||
opts: { key?: SigningKey; issuer?: string; expiresIn?: string } = {},
|
||||
) {
|
||||
return new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||||
.setIssuedAt()
|
||||
.setIssuer(opts.issuer ?? origin)
|
||||
.setExpirationTime(opts.expiresIn ?? '5m')
|
||||
.sign(opts.key ?? privateKey);
|
||||
}
|
||||
|
||||
assert.deepEqual(await provider.verifyAccessToken(token), {
|
||||
subject: 'provider-user-1',
|
||||
email: 'Owner@Example.com',
|
||||
});
|
||||
describe('OIDC provider — what it accepts', () => {
|
||||
it('verifies a well-formed token and returns subject and email', async () => {
|
||||
const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||
const token = await mint({ sub: 'user-1', email: 'Person@Example.com', aud: 'pig' });
|
||||
const identity = await provider.verifyAccessToken(token);
|
||||
|
||||
assert.equal(identity.subject, 'user-1');
|
||||
// Lower-cased, because PIG keys membership on the address and
|
||||
// Person@ and person@ are the same person.
|
||||
assert.equal(identity.email, 'person@example.com');
|
||||
});
|
||||
|
||||
it('rejects a correctly signed token issued for a different identity provider', async () => {
|
||||
// Signature validity alone is insufficient: without the issuer check, a
|
||||
// sibling deployment using the same key could authenticate here.
|
||||
const token = await sign({ sub: 'provider-user-1' }, 'https://other.example/auth/v1');
|
||||
|
||||
await assert.rejects(provider.verifyAccessToken(token));
|
||||
it('discovers the JWKS from the issuer when no explicit URI is given', async () => {
|
||||
const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||
const token = await mint({ sub: 'user-2', email: 'a@b.com', aud: 'pig' });
|
||||
assert.equal((await provider.verifyAccessToken(token)).subject, 'user-2');
|
||||
});
|
||||
|
||||
it('accepts a subject without email for protected requests', async () => {
|
||||
// Existing members are joined by subject. Email is required only by the
|
||||
// invite-gated profile flow, not as an extra condition on every request.
|
||||
const token = await sign({ sub: 'provider-user-2' });
|
||||
|
||||
assert.deepEqual(await provider.verifyAccessToken(token), {
|
||||
subject: 'provider-user-2',
|
||||
email: undefined,
|
||||
});
|
||||
it('skips discovery entirely when the JWKS URI is configured', async () => {
|
||||
// The air-gapped path: no discovery request is made at all.
|
||||
serveDiscovery = false;
|
||||
try {
|
||||
const provider = createOidcAuthProvider({
|
||||
issuer: origin,
|
||||
jwksUri: `${origin}/jwks`,
|
||||
audience: 'pig',
|
||||
});
|
||||
const token = await mint({ sub: 'user-3', email: 'a@b.com', aud: 'pig' });
|
||||
assert.equal((await provider.verifyAccessToken(token)).subject, 'user-3');
|
||||
} finally {
|
||||
serveDiscovery = true;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a token with no stable subject', async () => {
|
||||
const token = await sign({ email: 'owner@example.com' });
|
||||
it('falls back through email claims for providers that do not send `email`', async () => {
|
||||
const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||
const token = await mint({ sub: 'user-4', preferred_username: 'someone@corp.com', aud: 'pig' });
|
||||
assert.equal((await provider.verifyAccessToken(token)).email, 'someone@corp.com');
|
||||
});
|
||||
|
||||
await assert.rejects(provider.verifyAccessToken(token));
|
||||
it('ignores a preferred_username that is not an address', async () => {
|
||||
// A bare username must never become an account identity — PIG keys
|
||||
// membership on the email, and "jsmith" is not one.
|
||||
const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||
const token = await mint({ sub: 'user-5', preferred_username: 'jsmith', aud: 'pig' });
|
||||
assert.equal((await provider.verifyAccessToken(token)).email, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC provider — what it must refuse', () => {
|
||||
const provider = () => createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||
|
||||
it('rejects a token signed by a different key', async () => {
|
||||
const token = await mint({ sub: 'x', aud: 'pig' }, { key: otherPrivateKey });
|
||||
await assert.rejects(() => provider().verifyAccessToken(token));
|
||||
});
|
||||
|
||||
it('rejects a token from a different issuer', async () => {
|
||||
const token = await mint({ sub: 'x', aud: 'pig' }, { issuer: 'https://evil.example' });
|
||||
await assert.rejects(() => provider().verifyAccessToken(token));
|
||||
});
|
||||
|
||||
it('rejects a token minted for a DIFFERENT application in the same tenant', async () => {
|
||||
// The case the audience check exists for. Without it, a token issued for
|
||||
// any other internal tool would be accepted as a PIG session.
|
||||
const token = await mint({ sub: 'x', aud: 'some-other-app' });
|
||||
await assert.rejects(() => provider().verifyAccessToken(token));
|
||||
});
|
||||
|
||||
it('rejects an expired token', async () => {
|
||||
const token = await mint({ sub: 'x', aud: 'pig' }, { expiresIn: '-1m' });
|
||||
await assert.rejects(() => provider().verifyAccessToken(token));
|
||||
});
|
||||
|
||||
it('rejects a token with no subject', async () => {
|
||||
const token = await mint({ aud: 'pig' });
|
||||
await assert.rejects(() => provider().verifyAccessToken(token));
|
||||
});
|
||||
|
||||
it('rejects garbage', async () => {
|
||||
await assert.rejects(() => provider().verifyAccessToken('not-a-token'));
|
||||
});
|
||||
|
||||
it('surfaces a discovery failure and retries on the next call', async () => {
|
||||
const p = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||
const token = await mint({ sub: 'user-6', aud: 'pig' });
|
||||
|
||||
discoveryStatus = 503;
|
||||
await assert.rejects(() => p.verifyAccessToken(token));
|
||||
|
||||
// The failure must not be cached for the process lifetime: an identity
|
||||
// provider that reboots should not permanently break PIG.
|
||||
discoveryStatus = 200;
|
||||
assert.equal((await p.verifyAccessToken(token)).subject, 'user-6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConfiguredAuthProvider', () => {
|
||||
it('prefers OIDC when both are configured', () => {
|
||||
const provider = createConfiguredAuthProvider({
|
||||
SUPABASE_URL: 'https://project.supabase.co',
|
||||
PIG_OIDC_ISSUER: 'https://id.customer.internal',
|
||||
PIG_OIDC_JWKS_URI: undefined,
|
||||
PIG_OIDC_AUDIENCE: 'pig',
|
||||
PIG_OIDC_EMAIL_CLAIMS: undefined,
|
||||
});
|
||||
// So an on-prem install can leave the hosted values in place without them
|
||||
// silently taking over.
|
||||
assert.equal(provider?.name, 'oidc');
|
||||
});
|
||||
|
||||
it('uses Supabase when only it is configured', () => {
|
||||
const provider = createConfiguredAuthProvider({
|
||||
SUPABASE_URL: 'https://project.supabase.co',
|
||||
PIG_OIDC_ISSUER: undefined,
|
||||
PIG_OIDC_JWKS_URI: undefined,
|
||||
PIG_OIDC_AUDIENCE: undefined,
|
||||
PIG_OIDC_EMAIL_CLAIMS: undefined,
|
||||
});
|
||||
assert.equal(provider?.name, 'supabase');
|
||||
});
|
||||
|
||||
it('returns null when neither is configured', () => {
|
||||
const provider = createConfiguredAuthProvider({
|
||||
SUPABASE_URL: undefined,
|
||||
PIG_OIDC_ISSUER: undefined,
|
||||
PIG_OIDC_JWKS_URI: undefined,
|
||||
PIG_OIDC_AUDIENCE: undefined,
|
||||
PIG_OIDC_EMAIL_CLAIMS: undefined,
|
||||
});
|
||||
// The production guard in config.ts turns this into a refusal to start.
|
||||
assert.equal(provider, null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CreateProfile } from '@/pages/CreateProfile';
|
||||
import { Register } from '@/pages/Register';
|
||||
import { PiggyMark } from '@/components/PiggyMark';
|
||||
import { EmptyState } from '@/components/ui';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
const Overview = lazy(() => import('@/pages/Overview').then(({ Overview }) => ({ default: Overview })));
|
||||
@@ -79,6 +80,8 @@ export function App() {
|
||||
<BrowserRouter>
|
||||
<AuthGate config={config} />
|
||||
</BrowserRouter>
|
||||
{/* Inside ThemeProvider: the host reads the resolved light/dark value. */}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,7 @@ export function AdminSettings() {
|
||||
<div className="relative overflow-hidden border-b border-border bg-surface-2 px-4 py-5 sm:px-6">
|
||||
<div className="absolute -right-12 -top-20 size-48 rounded-full bg-accent-subtle blur-3xl" aria-hidden />
|
||||
<div className="relative flex items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-accent text-accent-on">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary text-accent-on">
|
||||
<ShieldCheck aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from '@/components/ui/sheet';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ApiError, compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface AvailabilityRow {
|
||||
commitmentId: string;
|
||||
@@ -295,10 +296,24 @@ export function AllocationSheet({
|
||||
})
|
||||
: post<AllocationRecord>('/api/allocations', { ...body, status: values.status });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (allocation, values) => {
|
||||
await refresh();
|
||||
onOpenChange(false);
|
||||
// The sheet closes on success, so without this the only evidence the
|
||||
// write landed is a number moving somewhere off-screen. Report what
|
||||
// actually happened, in the units the seller was thinking in.
|
||||
const hours = Number(allocation.gpuHours ?? values.gpuHours).toLocaleString();
|
||||
toast.success(
|
||||
values.kind === 'hold' ? 'Capacity held' : 'Capacity allocated',
|
||||
{
|
||||
description:
|
||||
values.kind === 'hold'
|
||||
? `${hours} GPU-hours reserved. The hold releases automatically when it expires.`
|
||||
: `${hours} GPU-hours committed. Margin and utilisation have been updated.`,
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: (error) => toast.error('Could not save', { description: errorMessage(error) }),
|
||||
});
|
||||
const release = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
@@ -306,8 +321,16 @@ export function AllocationSheet({
|
||||
reason: releaseReason.trim() || undefined,
|
||||
}),
|
||||
onMutate: () => setReleaseError(null),
|
||||
onSuccess: refresh,
|
||||
onError: (error) => setReleaseError(errorMessage(error)),
|
||||
onSuccess: async () => {
|
||||
await refresh();
|
||||
toast.success('Hold released', {
|
||||
description: 'The capacity is available to sell again.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setReleaseError(errorMessage(error));
|
||||
toast.error('Could not release the hold', { description: errorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const chooseCommitment = (id: string) => {
|
||||
@@ -526,8 +549,8 @@ function CommitmentContext({ row, detail, match, quotedPrice }: { row: Availabil
|
||||
{match ? <Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>{percent(match.score)} fit</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface">
|
||||
<div className="bg-accent" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
|
||||
<div className="bg-accent/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
|
||||
<div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
|
||||
<div className="bg-primary/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 text-xs">
|
||||
<div><p className="text-muted">Sold</p><p className="nums mt-0.5 font-medium">{compactNumber(row.soldGpuHours)} hrs</p></div>
|
||||
|
||||
@@ -137,7 +137,7 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
|
||||
{files.isLoading ? <Skeleton className="h-40" /> : files.isError ? <ErrorText error={files.error} /> : files.data?.files.length === 0 ? <EmptyState title="No spreadsheets found" description="Try another name or confirm this Google account can see the spreadsheet." /> : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{files.data?.files.map((file) => (
|
||||
<button key={file.id} type="button" onClick={() => { setSpreadsheetId(file.id); setSheetId(''); }} className={spreadsheetId === file.id ? 'tap min-w-0 rounded-lg border border-accent bg-accent-subtle p-3 text-left' : 'tap min-w-0 rounded-lg border border-border p-3 text-left hover:bg-surface-2'}>
|
||||
<button key={file.id} type="button" onClick={() => { setSpreadsheetId(file.id); setSheetId(''); }} className={spreadsheetId === file.id ? 'tap min-w-0 rounded-lg border border-primary bg-accent-subtle p-3 text-left' : 'tap min-w-0 rounded-lg border border-border p-3 text-left hover:bg-surface-2'}>
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
<p className="mt-1 text-xs text-muted">{file.modifiedTime ? `Modified ${relativeTime(file.modifiedTime)}` : 'Modified time unavailable'}</p>
|
||||
</button>
|
||||
|
||||
@@ -264,7 +264,7 @@ function PiggyChatPanel({
|
||||
|
||||
function ChatMessage({ message }: { message: TranscriptMessage }) {
|
||||
if (message.role === 'user') {
|
||||
return <div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-accent px-4 py-3 text-sm text-accent-on"><p className="whitespace-pre-wrap">{message.content}</p></div>;
|
||||
return <div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-primary px-4 py-3 text-sm text-accent-on"><p className="whitespace-pre-wrap">{message.content}</p></div>;
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
|
||||
@@ -83,8 +83,22 @@ Input.displayName = 'Input';
|
||||
|
||||
// --------------------------------------------------------------------- card
|
||||
|
||||
/**
|
||||
* `min-w-0` is on the base class deliberately, not left to each call site.
|
||||
*
|
||||
* A grid or flex child defaults to `min-width: auto`, meaning it refuses to
|
||||
* shrink below its content — and cards routinely contain something
|
||||
* unshrinkable (a `tabular-nums` figure, a `whitespace-nowrap` badge, a long
|
||||
* unbroken title). The result is a card wider than its column, which drags the
|
||||
* whole page into horizontal scrolling on a phone.
|
||||
*
|
||||
* This has now been fixed three separate times at individual call sites, which
|
||||
* is the signal that it belongs here instead. `min-width: 0` is inert for a
|
||||
* block-level card outside a flex or grid container, so applying it always
|
||||
* costs nothing and removes the entire class of bug.
|
||||
*/
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('card', className)} {...props} />;
|
||||
return <div className={cn('card min-w-0', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
|
||||
@@ -1,29 +1,43 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
/**
|
||||
* Toast host.
|
||||
*
|
||||
* The shadcn original reads the theme from `next-themes`, which PIG does not
|
||||
* use — it has its own provider so a user's choice can be persisted server-side
|
||||
* and follow them between devices. Importing next-themes here would have thrown
|
||||
* at module load, which is why the Toaster was never mounted and every mutation
|
||||
* in the app completed in silence.
|
||||
*
|
||||
* Rewired to PIG's `useTheme`, which already resolves `system` to a concrete
|
||||
* light or dark value.
|
||||
*/
|
||||
import { Toaster as Sonner, type ToasterProps } from 'sonner';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
export function Toaster(props: ToasterProps) {
|
||||
const { resolved } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme={resolved}
|
||||
className="toaster group"
|
||||
// Above the sheets and dialogs it confirms, and clear of the phone tab
|
||||
// bar and the home indicator beneath it.
|
||||
position="bottom-right"
|
||||
offset="1rem"
|
||||
style={{ marginBottom: 'calc(var(--safe-bottom) + 4rem)' }}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
'group toast group-[.toaster]:bg-surface group-[.toaster]:text-fg ' +
|
||||
'group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted',
|
||||
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton: 'group-[.toast]:bg-surface-2 group-[.toast]:text-muted',
|
||||
error: 'group-[.toaster]:text-danger',
|
||||
success: 'group-[.toaster]:text-positive',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
|
||||
@@ -161,9 +161,9 @@ function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; wri
|
||||
bar made mostly of unconverted holds is a lie a seller would act on. */}
|
||||
<div>
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-surface-2">
|
||||
<div className="bg-accent" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
|
||||
<div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
|
||||
<div
|
||||
className="bg-accent/35"
|
||||
className="bg-primary/35"
|
||||
style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,7 @@ export function CreateProfile({
|
||||
className={[
|
||||
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
|
||||
team === value
|
||||
? 'border-accent bg-accent-subtle'
|
||||
? 'border-primary bg-accent-subtle'
|
||||
: 'border-border hover:bg-surface-2',
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { get, patch, relativeTime } from '@/lib/api';
|
||||
import { can } from '@/lib/permissions';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ReviewItem {
|
||||
fact: SourcedFact;
|
||||
@@ -74,7 +75,16 @@ export function FactReview() {
|
||||
const decision = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: 'approved' | 'dismissed' }) =>
|
||||
patch<DecisionResponse>(`/api/facts/${id}/decision`, { status }),
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (_result, variables) => {
|
||||
toast.success(
|
||||
variables.status === 'approved' ? 'Evidence accepted' : 'Claim dismissed',
|
||||
{
|
||||
description:
|
||||
variables.status === 'approved'
|
||||
? 'Recorded as reviewed. Applying it to the record is a separate, field-aware step.'
|
||||
: 'It will not be proposed again from the same evidence.',
|
||||
},
|
||||
);
|
||||
await queryClient.invalidateQueries({ queryKey: ['facts', 'proposed'] });
|
||||
},
|
||||
});
|
||||
@@ -99,7 +109,7 @@ export function FactReview() {
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
<Card className="overflow-hidden border-accent/30 bg-accent-subtle/40">
|
||||
<Card className="overflow-hidden border-primary/30 bg-accent-subtle/40">
|
||||
<CardContent className="flex gap-3 p-4 sm:p-5">
|
||||
<FileCheck2 className="mt-0.5 size-5 shrink-0 text-accent-fg" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -145,7 +145,7 @@ export function Imports() {
|
||||
type="button"
|
||||
aria-pressed={entity === candidate}
|
||||
onClick={() => resetForEntity(candidate)}
|
||||
className={entity === candidate ? 'tap card min-w-0 border-accent p-4 text-left ring-1 ring-accent' : 'tap card min-w-0 p-4 text-left'}
|
||||
className={entity === candidate ? 'tap card min-w-0 border-primary p-4 text-left ring-1 ring-primary' : 'tap card min-w-0 p-4 text-left'}
|
||||
>
|
||||
<p className="font-semibold">{candidateDefinition.label}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted">{candidateDefinition.description}</p>
|
||||
|
||||
@@ -184,7 +184,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
className={[
|
||||
'tap shrink-0 rounded-full px-3.5 text-sm font-medium transition-colors',
|
||||
stage === currentStage
|
||||
? 'bg-accent text-accent-on'
|
||||
? 'bg-primary text-accent-on'
|
||||
: 'bg-surface-2 text-muted',
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@@ -172,7 +172,7 @@ export function Register({
|
||||
className={[
|
||||
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
|
||||
team === value
|
||||
? 'border-accent bg-accent-subtle'
|
||||
? 'border-primary bg-accent-subtle'
|
||||
: 'border-border hover:bg-surface-2',
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '
|
||||
import { useState } from 'react';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { AdminSettings } from '@/components/AdminSettings';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
@@ -93,7 +94,7 @@ function Appearance() {
|
||||
title={option.label}
|
||||
className={[
|
||||
'tap relative flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
|
||||
selected ? 'border-accent bg-accent-subtle' : 'border-border hover:bg-surface-2',
|
||||
selected ? 'border-primary bg-accent-subtle' : 'border-border hover:bg-surface-2',
|
||||
].join(' ')}
|
||||
>
|
||||
{/*
|
||||
@@ -144,7 +145,11 @@ function Profile({ me }: { me: Me | undefined }) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
setName('');
|
||||
setTitle('');
|
||||
// The form clears itself on success, which without confirmation reads as
|
||||
// though the input was discarded rather than saved.
|
||||
toast.success('Profile saved');
|
||||
},
|
||||
onError: () => toast.error('Could not save your profile'),
|
||||
});
|
||||
|
||||
if (!me) return null;
|
||||
|
||||
@@ -20,8 +20,22 @@ export default {
|
||||
foreground: 'hsl(var(--fg))',
|
||||
muted: 'hsl(var(--muted))',
|
||||
'muted-foreground': 'hsl(var(--muted))',
|
||||
accent: 'hsl(var(--accent))',
|
||||
'accent-foreground': 'hsl(var(--accent-on))',
|
||||
/*
|
||||
* shadcn's `accent` is its SUBTLE hover/selected surface — dropdown
|
||||
* items, command rows, ghost-button hover. It is not the brand colour;
|
||||
* that is `primary`, mapped below.
|
||||
*
|
||||
* Mapping `accent` to --accent (the brand) inverted this, so every
|
||||
* hover state painted a full-strength brand block: in dark mode with
|
||||
* the monochrome "pig" accent that is near-white, which made a selected
|
||||
* command row glare. Aliased to the subtle pair instead, so shadcn
|
||||
* primitives tint the way their authors intended while PIG's own brand
|
||||
* fills use `primary`.
|
||||
*/
|
||||
accent: 'hsl(var(--accent-subtle))',
|
||||
'accent-foreground': 'hsl(var(--accent-fg))',
|
||||
/** The brand itself, for PIG's own components that need a solid fill. */
|
||||
brand: 'hsl(var(--accent))',
|
||||
'accent-fg': 'hsl(var(--accent-fg))',
|
||||
'accent-on': 'hsl(var(--accent-on))',
|
||||
'accent-subtle': 'hsl(var(--accent-subtle))',
|
||||
|
||||
Reference in New Issue
Block a user