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
|
- OAuth-ready login flow for Google, Microsoft, and Apple
|
||||||
- Multi-workspace model with `standard` household and `rescue` modes
|
- Multi-workspace model with `standard` household and `rescue` modes
|
||||||
- Shared workspace member management for both households and rescues
|
- 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 profiles with name, tag ID, and species
|
||||||
- Bird DOB and gotcha day fields
|
- Bird DOB and gotcha day fields
|
||||||
- Daily weight recordings
|
- Daily weight recordings
|
||||||
|
|||||||
+117
-23
@@ -13,7 +13,7 @@ dotenv.config();
|
|||||||
|
|
||||||
type WorkspaceType = 'standard' | 'rescue';
|
type WorkspaceType = 'standard' | 'rescue';
|
||||||
type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
|
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 ProviderKey = 'google' | 'microsoft' | 'apple';
|
||||||
|
|
||||||
type UserRow = {
|
type UserRow = {
|
||||||
@@ -177,7 +177,7 @@ const switchWorkspaceSchema = z.object({
|
|||||||
|
|
||||||
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
|
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
|
||||||
const workspaceRoleSchema = z.enum(['owner', 'manager', 'staff', 'viewer']);
|
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({
|
const workspaceSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(160),
|
name: z.string().trim().min(1).max(160),
|
||||||
@@ -199,6 +199,12 @@ const workspaceMemberSchema = z.object({
|
|||||||
role: workspaceRoleSchema,
|
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({
|
const birdSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
tagId: z.string().trim().min(1).max(80),
|
tagId: z.string().trim().min(1).max(80),
|
||||||
@@ -236,11 +242,18 @@ const createRandomId = () => crypto.randomUUID();
|
|||||||
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
|
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
|
||||||
const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('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') {
|
if (workspaceType === 'rescue') {
|
||||||
return 'rescue_free' as const;
|
return 'rescue_free' as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requestedPlan === 'household_macaw') {
|
||||||
|
return 'household_macaw';
|
||||||
|
}
|
||||||
|
|
||||||
return requestedPlan === 'household_plus' ? 'household_plus' : 'household_basic';
|
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) => {
|
const readBearerToken = (authorizationHeader?: string) => {
|
||||||
if (!authorizationHeader) {
|
if (!authorizationHeader) {
|
||||||
return '';
|
return '';
|
||||||
@@ -1041,28 +1088,10 @@ app.post('/api/auth/magic-link/request', async (req: Request, res: Response, nex
|
|||||||
const redirectTo = parsed.data.redirectTo || frontendBaseUrl;
|
const redirectTo = parsed.data.redirectTo || frontendBaseUrl;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pool.query(
|
const delivery = await issueMagicLinkInvite({
|
||||||
`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({
|
|
||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
magicLinkUrl: verifyUrl.toString(),
|
redirectTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(202).json({
|
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) => {
|
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() : '';
|
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 {
|
.dashboard-grid {
|
||||||
grid-template-columns: 320px 1fr;
|
grid-template-columns: 320px 1fr;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forms-grid {
|
.forms-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-grid {
|
.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,
|
.flock-member-panel,
|
||||||
@@ -412,6 +423,7 @@ textarea {
|
|||||||
.panel {
|
.panel {
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
align-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@@ -422,6 +434,18 @@ textarea {
|
|||||||
margin-bottom: 1rem;
|
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 {
|
.button-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -433,12 +457,6 @@ textarea {
|
|||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-switcher-header {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-switcher-header small,
|
|
||||||
.workspace-switcher-item small {
|
.workspace-switcher-item small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -681,6 +699,30 @@ textarea {
|
|||||||
font-size: 1.05rem;
|
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 span,
|
||||||
.vet-visit-card small {
|
.vet-visit-card small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -882,6 +924,10 @@ label {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
column-count: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.side-nav {
|
.side-nav {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user