Further UI improvements
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+53
-7
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user