+
{group.assignedDriverName || —}
diff --git a/src/router.jsx b/src/router.jsx
index 2a84da7..20f16de 100644
--- a/src/router.jsx
+++ b/src/router.jsx
@@ -1,5 +1,5 @@
import React from "react";
-import { Navigate, createBrowserRouter } from "react-router-dom";
+import { Navigate, createBrowserRouter, Outlet } from "react-router-dom";
import App from "./App";
import { ClientDeliveryPage } from "./pages/ClientDeliveryPage";
import { DashboardPage } from "./pages/DashboardPage";
@@ -7,6 +7,27 @@ import { GroupDetailPage } from "./pages/GroupDetailPage";
import { LoginPage } from "./pages/LoginPage";
import { NotFoundPage } from "./pages/NotFoundPage";
import { ForbiddenPage } from "./pages/ForbiddenPage";
+import { useAuth } from "./context/AuthContext";
+
+/**
+ * Protects routes that require authentication.
+ * Redirects to /login with return URL if no user and session is loaded.
+ * Shows nothing while session is being restored (avoids flash-of-login).
+ */
+const RequireAuth = ({ children }) => {
+ const { user, isSessionLoading } = useAuth();
+
+ if (isSessionLoading) {
+ return null;
+ }
+
+ if (!user) {
+ const redirect = encodeURIComponent(window.location.pathname + window.location.search);
+ return
;
+ }
+
+ return children;
+};
export const router = createBrowserRouter([
{
@@ -31,11 +52,19 @@ export const router = createBrowserRouter([
},
{
path: "dashboard",
- element:
,
+ element: (
+
+
+
+ ),
},
{
path: "dashboard/group/:groupId",
- element:
,
+ element: (
+
+
+
+ ),
},
{
path: "*",
diff --git a/supabase/functions/_shared/security.ts b/supabase/functions/_shared/security.ts
index 12682e1..02cb310 100644
--- a/supabase/functions/_shared/security.ts
+++ b/supabase/functions/_shared/security.ts
@@ -31,7 +31,7 @@ export function getCorsHeaders(request: Request, _access: 'public' | 'private')
'Access-Control-Max-Age': '86400',
};
}
- const allowed = ALLOWED_ORIGINS.some((o) => origin.startsWith(o));
+ const allowed = ALLOWED_ORIGINS.includes(origin);
if (!allowed) return null;
return {
'Access-Control-Allow-Origin': origin,
@@ -113,60 +113,104 @@ class RateLimitError extends Error {
export async function requireRateLimit(supabase: ReturnType
, options: RateLimitOptions) {
const { scope, key, maxCount, windowSeconds, blockSeconds } = options;
- const tableName = 'rate_limits';
- const now = new Date();
- const { data: blocked } = await supabase
- .from(tableName)
- .select('blocked_until')
- .eq('scope', scope)
- .eq('rate_key', key)
- .gt('blocked_until', now.toISOString())
- .limit(1);
-
- if (blocked && blocked.length > 0) {
- throw new RateLimitError('Too many requests. Please try again later.', 429);
- }
-
- const windowStart = new Date(now.getTime() - windowSeconds * 1000);
- const { data: recent, error } = await supabase
- .from(tableName)
- .select('id, count')
- .eq('scope', scope)
- .eq('rate_key', key)
- .gte('window_start', windowStart.toISOString());
+ // Use atomic SQL function to avoid race conditions
+ const { data, error } = await supabase.rpc('check_rate_limit', {
+ p_scope: scope,
+ p_key: key,
+ p_max_count: maxCount,
+ p_window_seconds: windowSeconds,
+ p_block_seconds: blockSeconds,
+ });
if (error) {
console.error('Rate limit check error:', error);
return;
}
- const totalCount = recent?.reduce((sum: number, r: { count: number }) => sum + r.count, 0) ?? 0;
-
- if (totalCount >= maxCount) {
- const blockedUntil = new Date(now.getTime() + blockSeconds * 1000);
- await supabase
- .from(tableName)
- .update({ blocked_until: blockedUntil.toISOString() })
- .eq('scope', scope)
- .eq('rate_key', key)
- .gte('window_start', windowStart.toISOString());
+ if (data && !data.allowed) {
throw new RateLimitError('Too many requests. Please try again later.', 429);
}
+}
- const existingRow = recent?.[0];
- if (existingRow) {
- await supabase
- .from(tableName)
- .update({ count: (existingRow as { count: number }).count + 1 })
- .eq('id', (existingRow as { id: string }).id);
- } else {
- await supabase.from(tableName).insert({
- scope,
- rate_key: key,
- window_start: now.toISOString(),
- count: 1,
- blocked_until: null,
- });
+// ── UUID validation ──────────────────────────────────────────────────────
+
+const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export function isValidUuid(value: unknown): value is string {
+ return typeof value === 'string' && UUID_REGEX.test(value.trim());
+}
+
+export function requireUuid(value: unknown, fieldName = 'id'): string {
+ if (!isValidUuid(value)) {
+ throw Object.assign(new Error(`${fieldName} must be a valid UUID`), { status: 400 });
}
+ return (value as string).trim();
+}
+
+// ── PII masking ──────────────────────────────────────────────────────────
+
+export function maskCustomerName(name: string | null | undefined): string | null {
+ if (!name || typeof name !== 'string') return null;
+ const trimmed = name.trim();
+ if (!trimmed) return null;
+ const firstChar = trimmed[0];
+ return `${firstChar}.`;
+}
+
+export function maskPhoneNumber(phone: string | null | undefined): string | null {
+ if (!phone || typeof phone !== 'string') return null;
+ const trimmed = phone.trim();
+ if (!trimmed) return null;
+ if (trimmed.length < 4) return trimmed;
+ return `+7 *** ***-${trimmed.slice(-2)}`;
+}
+
+// ── Same-origin / CSRF check ─────────────────────────────────────────────
+
+export function requireSameOrigin(request: Request, allowedOrigins: string[]): boolean {
+ const origin = request.headers.get('origin') || '';
+ if (!origin) return false;
+ return allowedOrigins.includes(origin);
+}
+
+// ── Internal request verification (HMAC or shared secret) ────────────────
+
+interface VerifyInternalOptions {
+ rawBody?: Uint8Array | string;
+ secretEnvNames?: string[];
+ tokenEnvNames?: string[];
+ allowedClockSkewSeconds?: number;
+}
+
+export async function verifyInternalRequest(
+ request: Request,
+ _rawBody: Uint8Array | string | undefined,
+ options: VerifyInternalOptions = {},
+): Promise<{ authenticated: boolean; authenticatedBy: string }> {
+ const {
+ secretEnvNames = ['INTEGRATION_WEBHOOK_SECRET'],
+ tokenEnvNames = ['INTEGRATION_API_KEY'],
+ } = options;
+
+ // Check for shared secret via header
+ const authHeader = request.headers.get('x-internal-secret') || request.headers.get('x-webhook-secret') || '';
+ for (const envName of secretEnvNames) {
+ const secret = Deno.env.get(envName);
+ if (secret && authHeader && authHeader === secret) {
+ return { authenticated: true, authenticatedBy: envName };
+ }
+ }
+
+ // Check for API token via header
+ const tokenHeader = request.headers.get('x-internal-token') || request.headers.get('x-api-key') || '';
+ for (const envName of tokenEnvNames) {
+ const token = Deno.env.get(envName);
+ if (token && tokenHeader && tokenHeader === token) {
+ return { authenticated: true, authenticatedBy: envName };
+ }
+ }
+
+ // No valid credentials found
+ throw Object.assign(new Error('Unauthorized: invalid or missing internal credentials'), { status: 401 });
}
\ No newline at end of file
diff --git a/supabase/functions/request-otp/index.ts b/supabase/functions/request-otp/index.ts
index f28569f..f5704d8 100644
--- a/supabase/functions/request-otp/index.ts
+++ b/supabase/functions/request-otp/index.ts
@@ -89,13 +89,11 @@ Deno.serve(async (request) => {
const clientIp = getClientIp(request);
const userAgent = request.headers.get("user-agent") || null;
- // Insert with plaintext otp_code so DB webhook "send_pin" delivers it to n8n
- // n8n will clear otp_code after sending SMS
+ // Insert with OTP hash only — no plaintext in DB
const { error: insertError } = await supabase.from("login_otps").insert({
email,
name: userName,
role: userRole,
- otp_code: otp,
otp_code_hash: otpCodeHash,
ip_address: clientIp,
user_agent: userAgent,
diff --git a/supabase/functions/verify-otp/index.ts b/supabase/functions/verify-otp/index.ts
index 1fed0f0..f3df5b6 100644
--- a/supabase/functions/verify-otp/index.ts
+++ b/supabase/functions/verify-otp/index.ts
@@ -81,26 +81,22 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders);
}
- // 3. Verify OTP — compare hash (new) with fallback to plaintext (old records)
+ // 3. Verify OTP — compare SHA-256 hashes only (no plaintext fallback)
const submittedOtpHash = await hashText(otp);
let otpMatches = false;
if (otpRecord.otp_code_hash) {
- // New flow: compare SHA-256 hashes
otpMatches = otpRecord.otp_code_hash === submittedOtpHash;
- } else if (otpRecord.otp_code) {
- // Legacy fallback: plaintext comparison for old records
- otpMatches = otpRecord.otp_code === otp;
}
if (!otpMatches) {
return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders);
}
- // 4. Mark as verified and clear plaintext if present
+ // 4. Mark as verified
await supabase
.from("login_otps")
- .update({ verified: true, otp_code: "" })
+ .update({ verified: true })
.eq("id", otpRecord.id);
// Delete all other unverified OTPs for this email
diff --git a/supabase/schema.sql b/supabase/schema.sql
index b0962c0..9424bf1 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -709,8 +709,18 @@ begin
'state', v_state,
'token', p_token,
'orderNumber', coalesce(nullif(v_order.order_number, ''), nullif(v_invitation.order_number, '')),
- 'customerName', coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, '')),
- 'customerPhone', coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')),
+ 'customerName',
+ case
+ when length(coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, ''))) > 0
+ then left(coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, '')), 1) || '.'
+ else null
+ end,
+ 'customerPhone',
+ case
+ when length(coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, ''))) >= 4
+ then '+7 *** ***-' || right(coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')), 2)
+ else coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, ''))
+ end,
'orderItems', v_order_items,
'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb),
'deliveryDate', v_invitation.delivery_date,
@@ -934,7 +944,7 @@ revoke all on function public.get_delivery_invitation_by_token(text) from public
grant execute on function public.get_delivery_invitation_by_token(text) to anon, authenticated;
revoke all on function public.confirm_delivery_choice_by_token(text, date, text) from public;
-grant execute on function public.confirm_delivery_choice_by_token(text, date, text) to anon, authenticated;
+grant execute on function public.confirm_delivery_choice_by_token(text, date, text) to authenticated;
alter table public.roles enable row level security;
alter table public.users enable row level security;
diff --git a/volumes/functions/_shared/security.ts b/volumes/functions/_shared/security.ts
index 12682e1..02cb310 100644
--- a/volumes/functions/_shared/security.ts
+++ b/volumes/functions/_shared/security.ts
@@ -31,7 +31,7 @@ export function getCorsHeaders(request: Request, _access: 'public' | 'private')
'Access-Control-Max-Age': '86400',
};
}
- const allowed = ALLOWED_ORIGINS.some((o) => origin.startsWith(o));
+ const allowed = ALLOWED_ORIGINS.includes(origin);
if (!allowed) return null;
return {
'Access-Control-Allow-Origin': origin,
@@ -113,60 +113,104 @@ class RateLimitError extends Error {
export async function requireRateLimit(supabase: ReturnType, options: RateLimitOptions) {
const { scope, key, maxCount, windowSeconds, blockSeconds } = options;
- const tableName = 'rate_limits';
- const now = new Date();
- const { data: blocked } = await supabase
- .from(tableName)
- .select('blocked_until')
- .eq('scope', scope)
- .eq('rate_key', key)
- .gt('blocked_until', now.toISOString())
- .limit(1);
-
- if (blocked && blocked.length > 0) {
- throw new RateLimitError('Too many requests. Please try again later.', 429);
- }
-
- const windowStart = new Date(now.getTime() - windowSeconds * 1000);
- const { data: recent, error } = await supabase
- .from(tableName)
- .select('id, count')
- .eq('scope', scope)
- .eq('rate_key', key)
- .gte('window_start', windowStart.toISOString());
+ // Use atomic SQL function to avoid race conditions
+ const { data, error } = await supabase.rpc('check_rate_limit', {
+ p_scope: scope,
+ p_key: key,
+ p_max_count: maxCount,
+ p_window_seconds: windowSeconds,
+ p_block_seconds: blockSeconds,
+ });
if (error) {
console.error('Rate limit check error:', error);
return;
}
- const totalCount = recent?.reduce((sum: number, r: { count: number }) => sum + r.count, 0) ?? 0;
-
- if (totalCount >= maxCount) {
- const blockedUntil = new Date(now.getTime() + blockSeconds * 1000);
- await supabase
- .from(tableName)
- .update({ blocked_until: blockedUntil.toISOString() })
- .eq('scope', scope)
- .eq('rate_key', key)
- .gte('window_start', windowStart.toISOString());
+ if (data && !data.allowed) {
throw new RateLimitError('Too many requests. Please try again later.', 429);
}
+}
- const existingRow = recent?.[0];
- if (existingRow) {
- await supabase
- .from(tableName)
- .update({ count: (existingRow as { count: number }).count + 1 })
- .eq('id', (existingRow as { id: string }).id);
- } else {
- await supabase.from(tableName).insert({
- scope,
- rate_key: key,
- window_start: now.toISOString(),
- count: 1,
- blocked_until: null,
- });
+// ── UUID validation ──────────────────────────────────────────────────────
+
+const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export function isValidUuid(value: unknown): value is string {
+ return typeof value === 'string' && UUID_REGEX.test(value.trim());
+}
+
+export function requireUuid(value: unknown, fieldName = 'id'): string {
+ if (!isValidUuid(value)) {
+ throw Object.assign(new Error(`${fieldName} must be a valid UUID`), { status: 400 });
}
+ return (value as string).trim();
+}
+
+// ── PII masking ──────────────────────────────────────────────────────────
+
+export function maskCustomerName(name: string | null | undefined): string | null {
+ if (!name || typeof name !== 'string') return null;
+ const trimmed = name.trim();
+ if (!trimmed) return null;
+ const firstChar = trimmed[0];
+ return `${firstChar}.`;
+}
+
+export function maskPhoneNumber(phone: string | null | undefined): string | null {
+ if (!phone || typeof phone !== 'string') return null;
+ const trimmed = phone.trim();
+ if (!trimmed) return null;
+ if (trimmed.length < 4) return trimmed;
+ return `+7 *** ***-${trimmed.slice(-2)}`;
+}
+
+// ── Same-origin / CSRF check ─────────────────────────────────────────────
+
+export function requireSameOrigin(request: Request, allowedOrigins: string[]): boolean {
+ const origin = request.headers.get('origin') || '';
+ if (!origin) return false;
+ return allowedOrigins.includes(origin);
+}
+
+// ── Internal request verification (HMAC or shared secret) ────────────────
+
+interface VerifyInternalOptions {
+ rawBody?: Uint8Array | string;
+ secretEnvNames?: string[];
+ tokenEnvNames?: string[];
+ allowedClockSkewSeconds?: number;
+}
+
+export async function verifyInternalRequest(
+ request: Request,
+ _rawBody: Uint8Array | string | undefined,
+ options: VerifyInternalOptions = {},
+): Promise<{ authenticated: boolean; authenticatedBy: string }> {
+ const {
+ secretEnvNames = ['INTEGRATION_WEBHOOK_SECRET'],
+ tokenEnvNames = ['INTEGRATION_API_KEY'],
+ } = options;
+
+ // Check for shared secret via header
+ const authHeader = request.headers.get('x-internal-secret') || request.headers.get('x-webhook-secret') || '';
+ for (const envName of secretEnvNames) {
+ const secret = Deno.env.get(envName);
+ if (secret && authHeader && authHeader === secret) {
+ return { authenticated: true, authenticatedBy: envName };
+ }
+ }
+
+ // Check for API token via header
+ const tokenHeader = request.headers.get('x-internal-token') || request.headers.get('x-api-key') || '';
+ for (const envName of tokenEnvNames) {
+ const token = Deno.env.get(envName);
+ if (token && tokenHeader && tokenHeader === token) {
+ return { authenticated: true, authenticatedBy: envName };
+ }
+ }
+
+ // No valid credentials found
+ throw Object.assign(new Error('Unauthorized: invalid or missing internal credentials'), { status: 401 });
}
\ No newline at end of file
diff --git a/volumes/functions/request-otp/index.ts b/volumes/functions/request-otp/index.ts
index f28569f..f5704d8 100644
--- a/volumes/functions/request-otp/index.ts
+++ b/volumes/functions/request-otp/index.ts
@@ -89,13 +89,11 @@ Deno.serve(async (request) => {
const clientIp = getClientIp(request);
const userAgent = request.headers.get("user-agent") || null;
- // Insert with plaintext otp_code so DB webhook "send_pin" delivers it to n8n
- // n8n will clear otp_code after sending SMS
+ // Insert with OTP hash only — no plaintext in DB
const { error: insertError } = await supabase.from("login_otps").insert({
email,
name: userName,
role: userRole,
- otp_code: otp,
otp_code_hash: otpCodeHash,
ip_address: clientIp,
user_agent: userAgent,
diff --git a/volumes/functions/verify-otp/index.ts b/volumes/functions/verify-otp/index.ts
index 1fed0f0..f3df5b6 100644
--- a/volumes/functions/verify-otp/index.ts
+++ b/volumes/functions/verify-otp/index.ts
@@ -81,26 +81,22 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders);
}
- // 3. Verify OTP — compare hash (new) with fallback to plaintext (old records)
+ // 3. Verify OTP — compare SHA-256 hashes only (no plaintext fallback)
const submittedOtpHash = await hashText(otp);
let otpMatches = false;
if (otpRecord.otp_code_hash) {
- // New flow: compare SHA-256 hashes
otpMatches = otpRecord.otp_code_hash === submittedOtpHash;
- } else if (otpRecord.otp_code) {
- // Legacy fallback: plaintext comparison for old records
- otpMatches = otpRecord.otp_code === otp;
}
if (!otpMatches) {
return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders);
}
- // 4. Mark as verified and clear plaintext if present
+ // 4. Mark as verified
await supabase
.from("login_otps")
- .update({ verified: true, otp_code: "" })
+ .update({ verified: true })
.eq("id", otpRecord.id);
// Delete all other unverified OTPs for this email