implemented
This commit is contained in:
parent
a31bd8246c
commit
d3638e2e69
18 changed files with 1643 additions and 120 deletions
|
|
@ -6,35 +6,18 @@ import { api } from "../../api";
|
|||
import { sharedStyles } from "../../styles/shared";
|
||||
import { Header } from "../../components/Header";
|
||||
import { useRequireAuth } from "../../hooks/useRequireAuth";
|
||||
import { components } from "../../generated/api";
|
||||
import constants from "../../../../shared/constants.json";
|
||||
|
||||
interface InviteRecord {
|
||||
id: number;
|
||||
identifier: string;
|
||||
godfather_id: number;
|
||||
godfather_email: string;
|
||||
status: string;
|
||||
used_by_id: number | null;
|
||||
used_by_email: string | null;
|
||||
created_at: string;
|
||||
spent_at: string | null;
|
||||
revoked_at: string | null;
|
||||
}
|
||||
const { READY, SPENT, REVOKED } = constants.inviteStatuses;
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
records: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: number;
|
||||
email: string;
|
||||
}
|
||||
// Use generated types from OpenAPI schema
|
||||
type InviteRecord = components["schemas"]["InviteResponse"];
|
||||
type PaginatedInvites = components["schemas"]["PaginatedResponse_InviteResponse_"];
|
||||
type UserOption = components["schemas"]["AdminUserResponse"];
|
||||
|
||||
export default function AdminInvitesPage() {
|
||||
const [data, setData] = useState<PaginatedResponse<InviteRecord> | null>(null);
|
||||
const [data, setData] = useState<PaginatedInvites | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
|
|
@ -63,7 +46,7 @@ export default function AdminInvitesPage() {
|
|||
if (status) {
|
||||
url += `&status=${status}`;
|
||||
}
|
||||
const data = await api.get<PaginatedResponse<InviteRecord>>(url);
|
||||
const data = await api.get<PaginatedInvites>(url);
|
||||
setData(data);
|
||||
} catch (err) {
|
||||
setData(null);
|
||||
|
|
@ -117,11 +100,11 @@ export default function AdminInvitesPage() {
|
|||
|
||||
const getStatusBadgeStyle = (status: string) => {
|
||||
switch (status) {
|
||||
case "ready":
|
||||
case READY:
|
||||
return styles.statusReady;
|
||||
case "spent":
|
||||
case SPENT:
|
||||
return styles.statusSpent;
|
||||
case "revoked":
|
||||
case REVOKED:
|
||||
return styles.statusRevoked;
|
||||
default:
|
||||
return {};
|
||||
|
|
@ -198,9 +181,9 @@ export default function AdminInvitesPage() {
|
|||
style={styles.filterSelect}
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="ready">Ready</option>
|
||||
<option value="spent">Spent</option>
|
||||
<option value="revoked">Revoked</option>
|
||||
<option value={READY}>Ready</option>
|
||||
<option value={SPENT}>Spent</option>
|
||||
<option value={REVOKED}>Revoked</option>
|
||||
</select>
|
||||
<span style={styles.totalCount}>
|
||||
{data?.total ?? 0} invites
|
||||
|
|
@ -240,7 +223,7 @@ export default function AdminInvitesPage() {
|
|||
</td>
|
||||
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
|
||||
<td style={styles.td}>
|
||||
{record.status === "ready" && (
|
||||
{record.status === READY && (
|
||||
<button
|
||||
onClick={() => handleRevoke(record.id)}
|
||||
style={styles.revokeButton}
|
||||
|
|
|
|||
|
|
@ -6,35 +6,17 @@ import { api } from "../api";
|
|||
import { sharedStyles } from "../styles/shared";
|
||||
import { Header } from "../components/Header";
|
||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||
import { components } from "../generated/api";
|
||||
|
||||
interface CounterRecord {
|
||||
id: number;
|
||||
user_email: string;
|
||||
value_before: number;
|
||||
value_after: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SumRecord {
|
||||
id: number;
|
||||
user_email: string;
|
||||
a: number;
|
||||
b: number;
|
||||
result: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
records: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
// Use generated types from OpenAPI schema
|
||||
type CounterRecord = components["schemas"]["CounterRecordResponse"];
|
||||
type SumRecord = components["schemas"]["SumRecordResponse"];
|
||||
type PaginatedCounterRecords = components["schemas"]["PaginatedResponse_CounterRecordResponse_"];
|
||||
type PaginatedSumRecords = components["schemas"]["PaginatedResponse_SumRecordResponse_"];
|
||||
|
||||
export default function AuditPage() {
|
||||
const [counterData, setCounterData] = useState<PaginatedResponse<CounterRecord> | null>(null);
|
||||
const [sumData, setSumData] = useState<PaginatedResponse<SumRecord> | null>(null);
|
||||
const [counterData, setCounterData] = useState<PaginatedCounterRecords | null>(null);
|
||||
const [sumData, setSumData] = useState<PaginatedSumRecords | null>(null);
|
||||
const [counterError, setCounterError] = useState<string | null>(null);
|
||||
const [sumError, setSumError] = useState<string | null>(null);
|
||||
const [counterPage, setCounterPage] = useState(1);
|
||||
|
|
@ -47,7 +29,7 @@ export default function AuditPage() {
|
|||
const fetchCounterRecords = useCallback(async (page: number) => {
|
||||
setCounterError(null);
|
||||
try {
|
||||
const data = await api.get<PaginatedResponse<CounterRecord>>(
|
||||
const data = await api.get<PaginatedCounterRecords>(
|
||||
`/api/audit/counter?page=${page}&per_page=10`
|
||||
);
|
||||
setCounterData(data);
|
||||
|
|
@ -60,7 +42,7 @@ export default function AuditPage() {
|
|||
const fetchSumRecords = useCallback(async (page: number) => {
|
||||
setSumError(null);
|
||||
try {
|
||||
const data = await api.get<PaginatedResponse<SumRecord>>(
|
||||
const data = await api.get<PaginatedSumRecords>(
|
||||
`/api/audit/sum?page=${page}&per_page=10`
|
||||
);
|
||||
setSumData(data);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@
|
|||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||
|
||||
import { api, ApiError } from "./api";
|
||||
import { components } from "./generated/api";
|
||||
|
||||
// Permission constants matching backend
|
||||
// Permission constants - must match backend/models.py Permission enum.
|
||||
// Backend exposes these via GET /api/meta/constants for validation.
|
||||
// TODO: Generate this from the backend endpoint at build time.
|
||||
export const Permission = {
|
||||
VIEW_COUNTER: "view_counter",
|
||||
INCREMENT_COUNTER: "increment_counter",
|
||||
|
|
@ -16,12 +19,8 @@ export const Permission = {
|
|||
|
||||
export type PermissionType = typeof Permission[keyof typeof Permission];
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
// Use generated type from OpenAPI schema
|
||||
type User = components["schemas"]["UserResponse"];
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "../auth-context";
|
||||
import { sharedStyles } from "../styles/shared";
|
||||
import constants from "../../../shared/constants.json";
|
||||
|
||||
const { ADMIN, REGULAR } = constants.roles;
|
||||
|
||||
type PageId = "counter" | "sum" | "profile" | "invites" | "audit" | "admin-invites";
|
||||
|
||||
|
|
@ -33,8 +36,8 @@ const ADMIN_NAV_ITEMS: NavItem[] = [
|
|||
export function Header({ currentPage }: HeaderProps) {
|
||||
const { user, logout, hasRole } = useAuth();
|
||||
const router = useRouter();
|
||||
const isRegularUser = hasRole("regular");
|
||||
const isAdminUser = hasRole("admin");
|
||||
const isRegularUser = hasRole(REGULAR);
|
||||
const isAdminUser = hasRole(ADMIN);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
|
|
|
|||
1120
frontend/app/generated/api.ts
Normal file
1120
frontend/app/generated/api.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -5,19 +5,15 @@ import { api } from "../api";
|
|||
import { sharedStyles } from "../styles/shared";
|
||||
import { Header } from "../components/Header";
|
||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||
import { components } from "../generated/api";
|
||||
import constants from "../../../shared/constants.json";
|
||||
|
||||
interface Invite {
|
||||
id: number;
|
||||
identifier: string;
|
||||
status: string;
|
||||
used_by_email: string | null;
|
||||
created_at: string;
|
||||
spent_at: string | null;
|
||||
}
|
||||
// Use generated type from OpenAPI schema
|
||||
type Invite = components["schemas"]["UserInviteResponse"];
|
||||
|
||||
export default function InvitesPage() {
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredRole: "regular",
|
||||
requiredRole: constants.roles.REGULAR,
|
||||
fallbackRedirect: "/audit",
|
||||
});
|
||||
const [invites, setInvites] = useState<Invite[]>([]);
|
||||
|
|
@ -71,9 +67,10 @@ export default function InvitesPage() {
|
|||
return null;
|
||||
}
|
||||
|
||||
const readyInvites = invites.filter((i) => i.status === "ready");
|
||||
const spentInvites = invites.filter((i) => i.status === "spent");
|
||||
const revokedInvites = invites.filter((i) => i.status === "revoked");
|
||||
const { READY, SPENT, REVOKED } = constants.inviteStatuses;
|
||||
const readyInvites = invites.filter((i) => i.status === READY);
|
||||
const spentInvites = invites.filter((i) => i.status === SPENT);
|
||||
const revokedInvites = invites.filter((i) => i.status === REVOKED);
|
||||
|
||||
return (
|
||||
<main style={styles.main}>
|
||||
|
|
|
|||
|
|
@ -6,15 +6,13 @@ import { api, ApiError } from "../api";
|
|||
import { sharedStyles } from "../styles/shared";
|
||||
import { Header } from "../components/Header";
|
||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||
import { components } from "../generated/api";
|
||||
import constants from "../../../shared/constants.json";
|
||||
|
||||
interface ProfileData {
|
||||
contact_email: string | null;
|
||||
telegram: string | null;
|
||||
signal: string | null;
|
||||
nostr_npub: string | null;
|
||||
godfather_email: string | null;
|
||||
}
|
||||
// Use generated type from OpenAPI schema
|
||||
type ProfileData = components["schemas"]["ProfileResponse"];
|
||||
|
||||
// UI-specific types (not from API)
|
||||
interface FormData {
|
||||
contact_email: string;
|
||||
telegram: string;
|
||||
|
|
@ -29,7 +27,9 @@ interface FieldErrors {
|
|||
nostr_npub?: string;
|
||||
}
|
||||
|
||||
// Client-side validation matching backend rules
|
||||
// Client-side validation using shared rules from constants
|
||||
const { telegram: telegramRules, signal: signalRules, nostrNpub: npubRules } = constants.validation;
|
||||
|
||||
function validateEmail(value: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
// More comprehensive email regex that matches email-validator behavior
|
||||
|
|
@ -43,15 +43,15 @@ function validateEmail(value: string): string | undefined {
|
|||
|
||||
function validateTelegram(value: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
if (!value.startsWith("@")) {
|
||||
return "Telegram handle must start with @";
|
||||
if (!value.startsWith(telegramRules.mustStartWith)) {
|
||||
return `Telegram handle must start with ${telegramRules.mustStartWith}`;
|
||||
}
|
||||
const handle = value.slice(1);
|
||||
if (handle.length < 1) {
|
||||
return "Telegram handle must have at least one character after @";
|
||||
return `Telegram handle must have at least one character after ${telegramRules.mustStartWith}`;
|
||||
}
|
||||
if (handle.length > 32) {
|
||||
return "Telegram handle must be at most 32 characters (after @)";
|
||||
if (handle.length > telegramRules.maxLengthAfterAt) {
|
||||
return `Telegram handle must be at most ${telegramRules.maxLengthAfterAt} characters (after ${telegramRules.mustStartWith})`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -61,16 +61,16 @@ function validateSignal(value: string): string | undefined {
|
|||
if (value.trim().length === 0) {
|
||||
return "Signal username cannot be empty";
|
||||
}
|
||||
if (value.length > 64) {
|
||||
return "Signal username must be at most 64 characters";
|
||||
if (value.length > signalRules.maxLength) {
|
||||
return `Signal username must be at most ${signalRules.maxLength} characters`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateNostrNpub(value: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
if (!value.startsWith("npub1")) {
|
||||
return "Nostr npub must start with 'npub1'";
|
||||
if (!value.startsWith(npubRules.prefix)) {
|
||||
return `Nostr npub must start with '${npubRules.prefix}'`;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -80,7 +80,7 @@ function validateNostrNpub(value: string): string | undefined {
|
|||
}
|
||||
// npub should decode to 32 bytes (256 bits) for a public key
|
||||
// In bech32, each character encodes 5 bits, so 32 bytes = 52 characters of data
|
||||
if (decoded.words.length !== 52) {
|
||||
if (decoded.words.length !== npubRules.bech32Words) {
|
||||
return "Invalid Nostr npub: incorrect length";
|
||||
}
|
||||
return undefined;
|
||||
|
|
@ -113,7 +113,7 @@ function toFormData(data: ProfileData): FormData {
|
|||
|
||||
export default function ProfilePage() {
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredRole: "regular",
|
||||
requiredRole: constants.roles.REGULAR,
|
||||
fallbackRedirect: "/audit",
|
||||
});
|
||||
const [originalData, setOriginalData] = useState<FormData | null>(null);
|
||||
|
|
@ -152,7 +152,7 @@ export default function ProfilePage() {
|
|||
const formValues = toFormData(data);
|
||||
setFormData(formValues);
|
||||
setOriginalData(formValues);
|
||||
setGodfatherEmail(data.godfather_email);
|
||||
setGodfatherEmail(data.godfather_email ?? null);
|
||||
} catch (err) {
|
||||
console.error("Profile load error:", err);
|
||||
setToast({ message: "Failed to load profile", type: "error" });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue