feat(auth,security,scheduling): JWT revocation, S3 fix, notes validation, docs, tests
- JWT revocation with JTI (UUID v4): in-memory tracking, POST /api/logout, refresh handler revokes old JTI, RequireAuth rejects revoked tokens - Fix extractKey for S3 portfolio deletion: extracts full key path from URLs instead of just filename, preventing orphaned storage files - Notes validation: max=1000000 on all 13 Notes fields across 4 booking structs - CharCounter: grapheme-aware counter (Intl.Segmenter), threshold 750K, color-coded, integrated into 6 booking/admin components - loginInProgress: timestamp-based tracking, 30s staleness, 20-entry cap (429), ticker cleanup for stuck entries - Profile picture 15MB client-side limit, portfolio 20MB backend limit - Exceptional scheduling: expand query start to Monday of week - TodayCalendar: week-range fetching, closing time indicator, short-day lunch skip - NavBar: link reorder, mobile burger badge, slide transition, backdrop - ImageUpload: 20MB limit with visual feedback - formatDateISO: shared YYYY-MM-DD utility, shouldApplyLunchProtection helper - Update README.md and all Obsidian docs (Overview, Technical, Admin, Future Work) - Add 28 new tests: JWT (11), auth handlers (7), portfolio extractKey (5), notes validation (5). go build + go vet clean with test,dev tags
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -893,6 +894,7 @@
|
||||
placeholder="Tell us why you need to make changes"
|
||||
rows={2}
|
||||
/>
|
||||
<CharCounter text={notes} />
|
||||
</div>
|
||||
{:else if editMode === 'services'}
|
||||
<!-- ─── Services Only (with time restriction) ── -->
|
||||
@@ -1018,6 +1020,7 @@
|
||||
placeholder="Any special requests or notes for your appointment"
|
||||
rows={2}
|
||||
/>
|
||||
<CharCounter text={notes} />
|
||||
</div>
|
||||
{:else if editMode === 'both-services'}
|
||||
<!-- ─── Both Step 1: Service Selection (no time restriction) ── -->
|
||||
@@ -1128,6 +1131,7 @@
|
||||
placeholder="Any special requests or notes for your appointment"
|
||||
rows={2}
|
||||
/>
|
||||
<CharCounter text={notes} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -492,6 +493,7 @@
|
||||
rows={3}
|
||||
class="w-full"
|
||||
/>
|
||||
<CharCounter text={notes} />
|
||||
{#if notes.trim() !== (booking.notes || '')}
|
||||
<div class="mt-1 text-xs text-emerald-600">
|
||||
✓ Notes will be saved (different from original)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||
|
||||
// Booking Components
|
||||
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||
@@ -1112,6 +1113,7 @@
|
||||
bind:value={notes}
|
||||
placeholder="Any special requirements, preferences, or notes about this booking..."
|
||||
></textarea>
|
||||
<CharCounter text={notes} />
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-between">
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||
import type { Booking, BookingService, Service } from '$lib/types/booking';
|
||||
|
||||
interface Props {
|
||||
@@ -614,6 +615,7 @@
|
||||
rows={3}
|
||||
class="w-full"
|
||||
/>
|
||||
<CharCounter text={notes} />
|
||||
{#if notes.trim() !== (booking.notes || '')}
|
||||
<div class="mt-1 text-xs text-emerald-600">Notes will be saved</div>
|
||||
{/if}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||
|
||||
// Booking Components
|
||||
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||
@@ -811,6 +812,7 @@
|
||||
bind:value={notes}
|
||||
placeholder="Any special requirements, preferences, or notes about this booking..."
|
||||
></textarea>
|
||||
<CharCounter text={notes} />
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-between">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
// INTENTIONAL: We use the browser's local timezone (getLocalTimeZone) because Crussell is a UK-only
|
||||
@@ -1659,6 +1660,7 @@
|
||||
placeholder="Any allergies, preferences, or special requirements..."
|
||||
rows={3}
|
||||
/>
|
||||
<CharCounter text={customerInfo.specialRequests} />
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600">
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
text: string;
|
||||
maxChars?: number;
|
||||
threshold?: number; // when to start showing counter
|
||||
};
|
||||
let { text, maxChars = 1000000, threshold = 750000 }: Props = $props();
|
||||
|
||||
const graphemeCount = $derived.by(() => {
|
||||
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
||||
return [...segmenter.segment(text)].length;
|
||||
});
|
||||
|
||||
const shouldShow = $derived(graphemeCount > threshold);
|
||||
const remaining = $derived(maxChars - graphemeCount);
|
||||
const color = $derived(
|
||||
graphemeCount > 950000 ? 'text-red-600' :
|
||||
graphemeCount > 800000 ? 'text-yellow-600' :
|
||||
'text-green-600'
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if shouldShow}
|
||||
<p class="text-xs {color}">
|
||||
{remaining.toLocaleString()} characters remaining ({graphemeCount.toLocaleString()} / {maxChars.toLocaleString()})
|
||||
</p>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user