Further UI improvements

This commit is contained in:
Corey Blais
2026-04-09 18:11:55 -04:00
parent 5b0304ee93
commit 132b0c1202
4 changed files with 817 additions and 467 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean,
- OAuth-ready login flow for Google, Microsoft, and Apple
- Multi-workspace model with `standard` household and `rescue` modes
- Shared workspace member management for both households and rescues
- Separate per-workspace billing plan foundation with `rescue_free`, `household_basic`, and `household_plus`
- Separate per-workspace billing plan foundation with `rescue_free`, `household_basic`, `household_plus`, and `household_macaw`
- Bird profiles with name, tag ID, and species
- Bird DOB and gotcha day fields
- Daily weight recordings
+117 -23
View File
@@ -13,7 +13,7 @@ dotenv.config();
type WorkspaceType = 'standard' | 'rescue';
type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus';
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
type ProviderKey = 'google' | 'microsoft' | 'apple';
type UserRow = {
@@ -177,7 +177,7 @@ const switchWorkspaceSchema = z.object({
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
const workspaceRoleSchema = z.enum(['owner', 'manager', 'staff', 'viewer']);
const billingPlanSchema = z.enum(['household_basic', 'household_plus']);
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
const workspaceSchema = z.object({
name: z.string().trim().min(1).max(160),
@@ -199,6 +199,12 @@ const workspaceMemberSchema = z.object({
role: workspaceRoleSchema,
});
const transferDraftSchema = z.object({
birdId: z.string().uuid(),
destinationOwnerEmail: z.string().trim().email().max(255),
notes: z.string().trim().max(1000).optional().or(z.literal('')),
});
const birdSchema = z.object({
name: z.string().trim().min(1).max(120),
tagId: z.string().trim().min(1).max(80),
@@ -236,11 +242,18 @@ const createRandomId = () => crypto.randomUUID();
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url');
const resolveBillingPlan = (workspaceType: WorkspaceType, requestedPlan?: BillingPlan | 'household_basic' | 'household_plus') => {
const resolveBillingPlan = (
workspaceType: WorkspaceType,
requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw',
) => {
if (workspaceType === 'rescue') {
return 'rescue_free' as const;
}
if (requestedPlan === 'household_macaw') {
return 'household_macaw';
}
return requestedPlan === 'household_plus' ? 'household_plus' : 'household_basic';
};
@@ -825,6 +838,40 @@ const sendMagicLink = async ({
};
};
const issueMagicLinkInvite = async ({
email,
name,
redirectTo = frontendBaseUrl,
}: {
email: string;
name: string | null;
redirectTo?: string;
}) => {
await pool.query(
`DELETE FROM magic_link_tokens
WHERE expires_at <= CURRENT_TIMESTAMP`,
);
const rawToken = createSessionToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
await pool.query(
`INSERT INTO magic_link_tokens (email, name, token_hash, redirect_to, expires_at)
VALUES ($1, $2, $3, $4, $5)`,
[email, name, tokenHash, redirectTo, expiresAt],
);
const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`);
verifyUrl.searchParams.set('token', rawToken);
return sendMagicLink({
email,
name,
magicLinkUrl: verifyUrl.toString(),
});
};
const readBearerToken = (authorizationHeader?: string) => {
if (!authorizationHeader) {
return '';
@@ -1041,28 +1088,10 @@ app.post('/api/auth/magic-link/request', async (req: Request, res: Response, nex
const redirectTo = parsed.data.redirectTo || frontendBaseUrl;
try {
await pool.query(
`DELETE FROM magic_link_tokens
WHERE expires_at <= CURRENT_TIMESTAMP`,
);
const rawToken = createSessionToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
await pool.query(
`INSERT INTO magic_link_tokens (email, name, token_hash, redirect_to, expires_at)
VALUES ($1, $2, $3, $4, $5)`,
[email, name, tokenHash, redirectTo, expiresAt],
);
const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`);
verifyUrl.searchParams.set('token', rawToken);
const delivery = await sendMagicLink({
const delivery = await issueMagicLinkInvite({
email,
name,
magicLinkUrl: verifyUrl.toString(),
redirectTo,
});
res.status(202).json({
@@ -1076,6 +1105,71 @@ app.post('/api/auth/magic-link/request', async (req: Request, res: Response, nex
}
});
app.post('/api/transfers/draft', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
const parsed = transferDraftSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid transfer draft payload', details: parsed.error.flatten() });
return;
}
try {
const birdResult = await pool.query<BirdRow>(
`SELECT ${birdSelectFields}
FROM birds
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
FROM weight_records
WHERE weight_records.bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
WHERE birds.id = $1
AND birds.workspace_id = $2`,
[parsed.data.birdId, req.auth!.workspace.id],
);
if (!birdResult.rowCount) {
res.status(404).json({ error: 'That bird could not be found in this workspace.' });
return;
}
const destinationOwnerEmail = normalizeEmail(parsed.data.destinationOwnerEmail);
const existingUser = await pool.query<UserRow>(
`SELECT id, email, password_hash, name, created_at
FROM users
WHERE email = $1`,
[destinationOwnerEmail],
);
let invitePreviewUrl: string | null = null;
let inviteDelivery: 'email' | 'preview' | null = null;
if (!existingUser.rowCount) {
const delivery = await issueMagicLinkInvite({
email: destinationOwnerEmail,
name: null,
redirectTo: frontendBaseUrl,
});
invitePreviewUrl = delivery.previewUrl;
inviteDelivery = delivery.delivered ? 'email' : 'preview';
}
res.status(201).json({
ok: true,
bird: normalizeBird(birdResult.rows[0]),
destinationOwnerEmail,
destinationOwnerExists: Boolean(existingUser.rowCount),
inviteSent: !existingUser.rowCount,
invitePreviewUrl,
inviteDelivery,
});
} catch (error) {
next(error);
}
});
app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next: NextFunction) => {
const rawToken = typeof req.query.token === 'string' ? req.query.token.trim() : '';
+646 -436
View File
File diff suppressed because it is too large Load Diff
+53 -7
View File
@@ -393,14 +393,25 @@ textarea {
.dashboard-grid {
grid-template-columns: 320px 1fr;
align-items: start;
}
.forms-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
}
.settings-grid {
align-items: start;
display: block;
column-count: 2;
column-gap: 1.5rem;
}
.settings-grid > .panel {
display: inline-grid;
width: 100%;
margin: 0 0 1.5rem;
break-inside: avoid;
}
.flock-member-panel,
@@ -412,6 +423,7 @@ textarea {
.panel {
border-radius: 28px;
padding: 1.5rem;
align-self: start;
}
.panel-header {
@@ -422,6 +434,18 @@ textarea {
margin-bottom: 1rem;
}
.pulse-panel .panel-header {
margin-bottom: 0.25rem;
}
.pulse-panel {
align-content: start;
}
.pulse-panel .summary-card {
align-content: start;
}
.button-row {
display: flex;
gap: 0.75rem;
@@ -433,12 +457,6 @@ textarea {
gap: 0.9rem;
}
.workspace-switcher-header {
display: grid;
gap: 0.2rem;
}
.workspace-switcher-header small,
.workspace-switcher-item small {
color: var(--muted);
}
@@ -681,6 +699,30 @@ textarea {
font-size: 1.05rem;
}
.summary-list {
display: grid;
gap: 0.2rem;
}
.summary-trend-row {
display: flex;
flex-wrap: wrap;
gap: 0.15rem;
align-items: baseline;
}
.summary-trend-name {
color: var(--ink);
}
.summary-trend-change.positive {
color: var(--accent-green);
}
.summary-trend-change.negative {
color: var(--accent-red);
}
.vet-visit-card span,
.vet-visit-card small {
color: var(--muted);
@@ -882,6 +924,10 @@ label {
padding: 1rem;
}
.settings-grid {
column-count: 1;
}
.side-nav {
position: static;
}