Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions apps/kurama-admin/src/core/middleware/admin-auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { getAuth } from '@kurama/data-ops/auth/server'
import { eq } from '@kurama/data-ops/database/drizzle-orm'
import { getDb } from '@kurama/data-ops/database/setup'
import { userProfiles } from '@kurama/data-ops/drizzle/schema'
import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'

Expand All @@ -11,14 +14,21 @@ async function getAuthContext() {
throw new Error('Unauthorized')
}

// TODO: Add admin role check here when schema is ready
// const adminRole = await db.query.adminRoles.findFirst(...)
const db = getDb()
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.user.id),
})

if (!profile || profile.userType !== 'admin') {
throw new Error('Forbidden: Admin access required')
}

return {
auth,
userId: session.user.id,
email: session.user.email,
session,
profile,
}
}

Expand Down
2 changes: 1 addition & 1 deletion apps/kurama-admin/src/routes/_admin/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ interface RecentSession {
userId: string
userName: string | null
userEmail: string | null
lessonId: number
lessonId: number | null
lessonTitle: string | null
mode: string
cardsReviewed: number
Expand Down
234 changes: 232 additions & 2 deletions apps/kurama-admin/src/routes/_admin/lessons.$lessonId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@ import {
ArrowLeft,
BookOpen,
Clock,
Copy,
ExternalLink,
Eye,
FileText,
GraduationCap,
Loader2,
Pencil,
Plus,
Save,
Sparkles,
Trash2,
Upload,
X,
} from 'lucide-react'
import { motion } from 'motion/react'
import { useState } from 'react'
import { toast } from 'sonner'
import { BulkImportDialog, CardForm, CardPreview } from '@/components/admin/cards'
import { AttachmentsSheet } from '@/components/admin/lessons/attachments-sheet'
import { MarkdownRenderer, PageHeader } from '@/components/shared'
import { ConfirmDialog, DataTable, MarkdownRenderer, PageHeader } from '@/components/shared'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Expand All @@ -45,6 +51,12 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import {
Expand All @@ -53,7 +65,14 @@ import {
saveGeneratedCards,
updateTeachPlan,
} from '@/core/functions/ai-generation'
import { getCards } from '@/core/functions/cards'
import {
bulkCreateCards,
createCard,
deleteCard,
duplicateCard,
getCards,
updateCard,
} from '@/core/functions/cards'
import { getLesson } from '@/core/functions/lessons'
import { getGradesSimple } from '@/core/functions/users'
import { generateUUID } from '@/utils/generateUUID'
Expand Down Expand Up @@ -118,6 +137,11 @@ function LessonDetailPage() {
difficulty: number
}>>([])
const [previewDialogOpen, setPreviewDialogOpen] = useState(false)
const [cardFormOpen, setCardFormOpen] = useState(false)
const [editingCard, setEditingCard] = useState<any>(null)
const [deletingCard, setDeletingCard] = useState<any>(null)
const [previewCard, setPreviewCard] = useState<any>(null)
const [importOpen, setImportOpen] = useState(false)

// Queries
const { data: lesson, isLoading } = useQuery({
Expand Down Expand Up @@ -230,6 +254,56 @@ function LessonDetailPage() {
},
})

// Card Mutations
const createCardMutation = useMutation({
mutationFn: (input: any) => createCard({ data: { ...input, lessonId: lessonIdNum } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cards', { lessonId: lessonIdNum }] })
setCardFormOpen(false)
toast.success('Carte créée avec succès')
},
onError: (error: Error) => toast.error(error.message),
})

const updateCardMutation = useMutation({
mutationFn: (input: any) => updateCard({ data: input }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cards', { lessonId: lessonIdNum }] })
setEditingCard(null)
toast.success('Carte modifiée avec succès')
},
onError: (error: Error) => toast.error(error.message),
})

const duplicateCardMutation = useMutation({
mutationFn: (id: number) => duplicateCard({ data: id }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cards', { lessonId: lessonIdNum }] })
toast.success('Carte dupliquée')
},
onError: (error: Error) => toast.error(error.message),
})

const deleteCardMutation = useMutation({
mutationFn: (id: number) => deleteCard({ data: id }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cards', { lessonId: lessonIdNum }] })
setDeletingCard(null)
toast.success('Carte supprimée')
},
onError: (error: Error) => toast.error(error.message),
})

const bulkImportMutation = useMutation({
mutationFn: (data: any) => bulkCreateCards({ data: { lessonId: lessonIdNum, cards: data } }),
onSuccess: (result: any) => {
queryClient.invalidateQueries({ queryKey: ['cards', { lessonId: lessonIdNum }] })
setImportOpen(false)
toast.success(`${result.created} cartes importées`)
},
onError: (error: Error) => toast.error(error.message),
})

const handleStartEdit = () => {
setEditedTeachPlan(lesson?.teachPlan || '')
setIsEditing(true)
Expand All @@ -240,6 +314,55 @@ function LessonDetailPage() {
setEditedTeachPlan('')
}

const cardTypeLabels: Record<string, string> = {
basic: 'Basique',
multichoice: 'Choix multiple',
true_false: 'Vrai/Faux',
fill_blank: 'Texte à trous',
}

const cardColumns = [
{
key: 'frontContent',
header: 'Contenu',
cell: (card: any) => (
<div className="max-w-[300px] truncate font-medium">
{card.frontContent}
</div>
),
},
{
key: 'cardType',
header: 'Type',
cell: (card: any) => (
<Badge variant="outline">
{cardTypeLabels[card.cardType] || card.cardType}
</Badge>
),
},
{
key: 'actions',
header: '',
cell: (card: any) => (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => setPreviewCard(card)}>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => duplicateCardMutation.mutate(card.id)}>
<Copy className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setEditingCard(card)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setDeletingCard(card)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
),
className: 'w-40',
},
]

const difficultyLabels: Record<string, string> = {
easy: 'Facile',
medium: 'Moyen',
Expand Down Expand Up @@ -719,6 +842,113 @@ function LessonDetailPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Cards List Section */}
<motion.div variants={item}>
<Card className="border-border/50 bg-background/50 backdrop-blur-xl shadow-lg">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-3 text-xl">
<div className="bg-primary/10 p-2 rounded-lg">
<FileText className="h-6 w-6 text-primary" />
</div>
Cartes de la leçon
</CardTitle>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<Upload className="mr-2 h-4 w-4" />
Import JSON
</Button>
<Button size="sm" onClick={() => setCardFormOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Nouvelle carte
</Button>
</div>
</CardHeader>
<CardContent>
<DataTable
columns={cardColumns}
data={cardsData?.cards || []}
page={cardsData?.page || 1}
totalPages={cardsData?.totalPages || 1}
total={cardsData?.total || 0}
onPageChange={() => { }} // Not implemented here as we show all
isLoading={isLoading}
emptyMessage="Aucune carte dans cette leçon."
/>
</CardContent>
</Card>
</motion.div>

{/* Card Form & Dialogs */}
{cardFormOpen && (
<CardForm
open={cardFormOpen}
onOpenChange={setCardFormOpen}
onSubmit={async (data) => { await createCardMutation.mutateAsync(data) }}
lessons={[{ id: lesson.id, title: lesson.title, subjectId: lesson.subjectId }]}
defaultValues={{ lessonId: lesson.id }}
isLoading={createCardMutation.isPending}
/>
)}

{editingCard && (
<CardForm
key={editingCard.id}
open={!!editingCard}
onOpenChange={open => !open && setEditingCard(null)}
onSubmit={async (data) => { await updateCardMutation.mutateAsync({ ...data, id: editingCard.id }) }}
lessons={[{ id: lesson.id, title: lesson.title, subjectId: lesson.subjectId }]}
defaultValues={editingCard}
isEditing
isLoading={updateCardMutation.isPending}
/>
)}

<ConfirmDialog
open={!!deletingCard}
onOpenChange={open => !open && setDeletingCard(null)}
title="Supprimer la carte"
description="Êtes-vous sûr de vouloir supprimer cette carte ?"
onConfirm={() => deleteCardMutation.mutate(deletingCard.id)}
isLoading={deleteCardMutation.isPending}
variant="destructive"
/>

<Sheet open={!!previewCard} onOpenChange={open => !open && setPreviewCard(null)}>
<SheetContent className="sm:max-w-lg">
<SheetHeader>
<SheetTitle>Aperçu de la carte</SheetTitle>
</SheetHeader>
{previewCard && (
<div className="mt-6">
<CardPreview {...previewCard} />
</div>
)}
</SheetContent>
</Sheet>

<BulkImportDialog
open={importOpen}
onOpenChange={setImportOpen}
lessonId={lessonIdNum}
onImport={async (cards) => {
const formattedCards = cards.map(card => ({
...card,
lessonId: lessonIdNum,
options: card.options?.map((opt, idx) => ({
id: idx.toString(),
text: opt,
isCorrect: card.correctAnswer === idx,
})),
correctAnswer: card.correctAnswer?.toString(),
difficulty: card.difficulty === 'easy' ? 0 : card.difficulty === 'medium' ? 1 : 2,
displayOrder: 0,
}))
await bulkImportMutation.mutateAsync(formattedCards)
}}
isLoading={bulkImportMutation.isPending}
/>
</motion.div>
)
}
3 changes: 2 additions & 1 deletion apps/kurama-admin/src/routes/_admin/users.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ interface UserData {
image: string | null
createdAt: string
profile: {
userType: 'student' | 'parent' | null
userType: 'student' | 'parent' | 'admin' | null
firstName: string | null
lastName: string | null
phone: string | null
Expand All @@ -55,6 +55,7 @@ interface Grade {
const userTypeLabels: Record<string, string> = {
student: 'Élève',
parent: 'Parent',
admin: 'Administrateur',
}

function UsersPage() {
Expand Down
19 changes: 7 additions & 12 deletions apps/user-application/src/components/auth/auth-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Link } from '@tanstack/react-router'
import { Suspense, useState } from 'react'
import { createLazyComponent } from '@/lib/lazy-helpers'
import { SocialAuth } from './social-auth'
Expand Down Expand Up @@ -83,27 +84,21 @@ export function AuthScreen() {
<p className="mt-8 text-center text-xs text-muted-foreground animate-in fade-in slide-in-from-bottom-5 duration-700 delay-200">
En continuant, vous acceptez nos
{' '}
<button
type="button"
<Link
to="/terms"
className="text-primary hover:text-primary/80 transition-colors font-medium underline underline-offset-4"
onClick={() => {
// TODO: Navigate to terms page
}}
>
Conditions d'utilisation
</button>
</Link>
{' '}
et notre
{' '}
<button
type="button"
<Link
to="/privacy"
className="text-primary hover:text-primary/80 transition-colors font-medium underline underline-offset-4"
onClick={() => {
// TODO: Navigate to privacy page
}}
>
Politique de confidentialité
</button>
</Link>
</p>
</div>
</div>
Expand Down
Loading
Loading