Merge branch 'main' into narl.io

This commit is contained in:
nvrl committed 2026-02-19 16:06:34 +01:00
commit 31a4f1af40
10 files changed
+345 -33

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
frontend-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install Dependencies
run: npm install
working-directory: frontend
- name: Build Frontend
run: npm run build
working-directory: frontend
backend-check:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.14.3'
cache: 'pip'
cache-dependency-path: backend/requirements.txt
- name: Install Dependencies
run: pip install -r requirements.txt
working-directory: backend
- name: Django System Check
run: python manage.py check
working-directory: backend
env:
SECRET_KEY: "ci-test-key"
DEBUG: "false"
ALLOWED_HOSTS: "localhost"
- name: Check Migrations
run: python manage.py makemigrations --check --dry-run
working-directory: backend
env:
SECRET_KEY: "ci-test-key"
DEBUG: "false"
ALLOWED_HOSTS: "localhost"
@@ -0,0 +1,18 @@
# Generated by Django 6.0.2 on 2026-02-19 14:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0026_banner_is_enabled_user_selected_banner_and_more'),
]
operations = [
migrations.AlterField(
model_name='notification',
name='notification_type',
field=models.CharField(choices=[('reply', 'Reply'), ('mention', 'Mention'), ('follow', 'Follow'), ('ticket_new', 'New Ticket'), ('ticket_reply', 'Ticket Reply'), ('profile_comment', 'Profile Comment')], max_length=20),
),
]
+1
View File
@@ -128,6 +128,7 @@ class Notification(models.Model):
TYPE_CHOICES = [
('reply', 'Reply'),
('mention', 'Mention'),
('follow', 'Follow'),
('ticket_new', 'New Ticket'),
('ticket_reply', 'Ticket Reply'),
('profile_comment', 'Profile Comment'),
+49 -3
View File
@@ -307,10 +307,12 @@ class UserViewSet(viewsets.ModelViewSet):
email_confirmation_code=code
)
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={username}&code={code}"
try:
send_mail(
'Verify your Holy Flavors account',
f'Hi {username},\n\nYour verification code is: {code}',
f'Hi {username},\n\nYour verification code is: {code}\n\nAlternatively, you can complete your registration by clicking the link below:\n{verification_link}\n\nWelcome to the archive!',
settings.DEFAULT_FROM_EMAIL,
[email],
fail_silently=False,
@@ -323,7 +325,42 @@ class UserViewSet(viewsets.ModelViewSet):
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({'status': 'User created, please verify your email'}, status=status.HTTP_201_CREATED)
return Response({'status': 'User created, please verify your email', 'username': username, 'email': email}, status=status.HTTP_201_CREATED)
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def resend_verification(self, request):
username = request.data.get('username')
if not username:
return Response({'error': 'Username required'}, status=status.HTTP_400_BAD_REQUEST)
try:
user = User.objects.get(username=username, is_active=False)
except User.DoesNotExist:
return Response({'error': 'User not found or already active'}, status=status.HTTP_404_NOT_FOUND)
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user.email_confirmation_code = code
user.save()
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={user.username}&code={code}"
try:
send_mail(
'Verify your Holy Flavors account',
f'Hi {user.username},\n\nYour new verification code is: {code}\n\nAlternatively, you can complete your registration by clicking the link below:\n{verification_link}',
settings.DEFAULT_FROM_EMAIL,
[user.email],
fail_silently=False,
)
except Exception as e:
return Response(
{'error': f'Failed to send email: {e}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({'status': 'Verification code resent!'})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def verify_signup(self, request):
@@ -516,7 +553,16 @@ class UserViewSet(viewsets.ModelViewSet):
user_to_follow = self.get_object()
if user_to_follow == request.user:
return Response({'error': 'You cannot follow yourself'}, status=status.HTTP_400_BAD_REQUEST)
request.user.following.add(user_to_follow)
if not request.user.following.filter(pk=user_to_follow.pk).exists():
request.user.following.add(user_to_follow)
# Notify the user being followed
Notification.objects.create(
recipient=user_to_follow,
actor=request.user,
notification_type='follow'
)
return Response({'status': 'followed'})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
+2
View File
@@ -191,6 +191,8 @@ REST_FRAMEWORK = {
CORS_ALLOWED_ORIGINS_STRING = os.environ.get('CORS_ALLOWED_ORIGINS', 'https://holy.narl.io,http://localhost:5173,http://127.0.0.1:5173')
CORS_ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ALLOWED_ORIGINS_STRING.split(',')]
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'https://holy.narl.io').rstrip('/')
import os
# ... (near top of file)
+7 -1
View File
@@ -55,6 +55,7 @@ const Privacy = lazy(() => import('./pages/Privacy'));
const Login = lazy(() => import('./pages/Login'));
const Settings = lazy(() => import('./pages/Settings'));
const Support = lazy(() => import('./pages/Support'));
const VerifyEmail = lazy(() => import('./pages/VerifyEmail'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail'));
@@ -73,7 +74,7 @@ interface Notification {
id: number;
actor_username: string;
actor_avatar: string | null;
notification_type: 'reply' | 'mention' | 'ticket_new' | 'ticket_reply' | 'profile_comment';
notification_type: 'reply' | 'mention' | 'follow' | 'ticket_new' | 'ticket_reply' | 'profile_comment';
rating: number | null;
reply: number | null;
is_read: boolean;
@@ -511,6 +512,9 @@ const App: React.FC = () => {
} else if (n.notification_type === 'profile_comment') {
handleNotificationClick(n);
navigate(`/profile/${user?.username}`);
} else if (n.notification_type === 'follow') {
handleNotificationClick(n);
navigate(`/profile/${n.actor_username}`);
} else {
handleNotificationClick(n);
}
@@ -540,6 +544,7 @@ const App: React.FC = () => {
<strong>{n.actor_username}</strong> {
n.notification_type === 'reply' ? `replied to your review on ${n.flavor_name}` :
n.notification_type === 'mention' ? `mentioned you on ${n.flavor_name}` :
n.notification_type === 'follow' ? t('community.notifFollow') :
n.notification_type === 'profile_comment' ? `left a message on your guestbook` :
n.notification_type === 'ticket_new' ? t('community.notifTicketNew') :
user?.is_superuser ? t('community.notifTicketReplyAdmin') : t('community.notifTicketReply')
@@ -696,6 +701,7 @@ const App: React.FC = () => {
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
<Route path="/support" element={<Support />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/admin-panel" element={<AdminDashboard />} />
<Route path="/admin-panel/user/:id" element={<AdminUserDetail />} />
<Route path="/admin-panel/rating/:id" element={<AdminRatingDetail />} />
+30
View File
@@ -118,6 +118,20 @@ const resources = {
"save": "Save",
"cancel": "Cancel"
},
"auth": {
"loginTitle": "Login",
"signupTitle": "Signup",
"verifyTitle": "Verify Account",
"verifyDesc": "Enter the code sent to your email for <strong>{{username}}</strong>.",
"resendButton": "Resend Verification Code",
"resendSuccess": "A new verification code has been sent to your email.",
"verifySuccess": "Account verified! You can now login.",
"unverifiedError": "Account not verified. Please verify your account to login.",
"loginFailed": "Login failed. Please check your credentials.",
"signupSuccess": "Signup successful! Please enter the verification code sent to your email.",
"forgotPassword": "Forgot Password?",
"backToAuth": "Back to Signup/Login"
},
"community": {
"title": "Community Activity",
"subtitle": "Stay updated with your circle's latest ratings and discussions.",
@@ -127,6 +141,7 @@ const resources = {
"notifications": "Recent Notifications",
"notifReply": "replied",
"notifMention": "mentioned you",
"notifFollow": "started following you",
"notifTicketNew": "opened a new support ticket",
"notifTicketReply": "replied to your support ticket",
"notifTicketReplyAdmin": "replied to a support ticket",
@@ -344,6 +359,20 @@ const resources = {
"save": "Speichern",
"cancel": "Abbrechen"
},
"auth": {
"loginTitle": "Anmelden",
"signupTitle": "Registrieren",
"verifyTitle": "Konto verifizieren",
"verifyDesc": "Gib den Code ein, der an deine E-Mail für <strong>{{username}}</strong> gesendet wurde.",
"resendButton": "Verifizierungscode erneut senden",
"resendSuccess": "Ein neuer Verifizierungscode wurde an deine E-Mail gesendet.",
"verifySuccess": "Konto verifiziert! Du kannst dich jetzt anmelden.",
"unverifiedError": "Konto nicht verifiziert. Bitte verifiziere dein Konto, um dich anzumelden.",
"loginFailed": "Anmeldung fehlgeschlagen. Bitte überprüfe deine Zugangsdaten.",
"signupSuccess": "Registrierung erfolgreich! Bitte gib den Verifizierungscode ein, der an deine E-Mail gesendet wurde.",
"forgotPassword": "Passwort vergessen?",
"backToAuth": "Zurück zur Anmeldung"
},
"community": {
"title": "Community-Aktivität",
"subtitle": "Bleib auf dem Laufenden mit den neuesten Bewertungen und Diskussionen deines Kreises.",
@@ -353,6 +382,7 @@ const resources = {
"notifications": "Neueste Benachrichtigungen",
"notifReply": "hat geantwortet",
"notifMention": "hat dich erwähnt",
"notifFollow": "folgt dir jetzt",
"notifTicketNew": "hat ein neues Support-Ticket eröffnet",
"notifTicketReply": "hat auf dein Support-Ticket geantwortet",
"notifTicketReplyAdmin": "hat auf ein Support-Ticket geantwortet",
+32 -17
View File
@@ -70,7 +70,7 @@ interface Notification {
id: number;
actor_username: string;
actor_avatar: string | null;
notification_type: 'reply' | 'mention';
notification_type: 'reply' | 'mention' | 'follow' | 'ticket_new' | 'ticket_reply' | 'profile_comment';
rating: number | null;
reply: number | null;
is_read: boolean;
@@ -385,22 +385,37 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
{notifications.length === 0 ? (
<Box sx={{ p: 3, textAlign: 'center' }}><Typography variant="body2" color="text.secondary">{t('community.noNotifications')}</Typography></Box>
) : (
notifications.map(n => (
<ListItemButton key={n.id} onClick={() => n.flavor_id && navigate(`/flavor/${n.flavor_id}`)} sx={{ py: 1.5 }}>
<ListItemAvatar sx={{ minWidth: 40 }}>
<Avatar src={n.actor_avatar || undefined} sx={{ width: 30, height: 30, fontSize: '0.8rem' }}>{n.actor_username.charAt(0)}</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
<strong>{n.actor_username}</strong> {n.notification_type === 'reply' ? t('community.notifReply') : t('community.notifMention')}
</Typography>
}
secondary={formatTimestamp(n.created_at)}
secondaryTypographyProps={{ sx: { fontSize: '0.7rem' } }}
/>
</ListItemButton>
))
notifications.map(n => {
const handleClick = () => {
if (n.notification_type === 'follow') {
navigate(`/profile/${n.actor_username}`);
} else if (n.flavor_id) {
navigate(`/flavor/${n.flavor_id}`);
}
};
return (
<ListItemButton key={n.id} onClick={handleClick} sx={{ py: 1.5 }}>
<ListItemAvatar sx={{ minWidth: 40 }}>
<Avatar src={n.actor_avatar || undefined} sx={{ width: 30, height: 30, fontSize: '0.8rem' }}>{n.actor_username.charAt(0)}</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
<strong>{n.actor_username}</strong> {
n.notification_type === 'reply' ? t('community.notifReply') :
n.notification_type === 'mention' ? t('community.notifMention') :
n.notification_type === 'follow' ? t('community.notifFollow') :
n.notification_type
}
</Typography>
}
secondary={formatTimestamp(n.created_at)}
secondaryTypographyProps={{ sx: { fontSize: '0.7rem' } }}
/>
</ListItemButton>
);
})
)}
</List>
</Card>
+45 -12
View File
@@ -4,11 +4,13 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { Link } from 'react-router-dom';
import api from '../api';
import { useTitle } from '../hooks/useTitle';
import { useTranslation } from 'react-i18next';
interface LoginProps {
}
const Login: React.FC<LoginProps> = () => {
const { t } = useTranslation();
useTitle('Login');
const [tab, setTab] = useState(0);
const [username, setUsername] = useState('');
@@ -16,6 +18,7 @@ const Login: React.FC<LoginProps> = () => {
const [email, setEmail] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [isVerifying, setIsVerifying] = useState(false);
const [showResend, setShowResend] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [resetStep, setResetStep] = useState(1); // 1: email, 2: code/new pass
const [message, setMessage] = useState<{type: 'success' | 'error', text: string} | null>(null);
@@ -31,7 +34,13 @@ const Login: React.FC<LoginProps> = () => {
localStorage.setItem('token', res.data.token);
window.location.href = '/';
} catch (err: any) {
setMessage({ type: 'error', text: 'Login failed. Please check your credentials or verify your account.' });
const errorMsg = err.response?.data?.non_field_errors?.[0] || t('auth.loginFailed');
if (errorMsg.toLowerCase().includes('inactive') || errorMsg.toLowerCase().includes('verify') || errorMsg.includes('disabled')) {
setMessage({ type: 'error', text: t('auth.unverifiedError') });
setIsVerifying(true);
} else {
setMessage({ type: 'error', text: errorMsg });
}
}
};
@@ -41,7 +50,7 @@ const Login: React.FC<LoginProps> = () => {
try {
await api.post('users/signup/', { username, email, password });
setIsVerifying(true);
setMessage({ type: 'success', text: 'Signup successful! Please enter the verification code sent to your email.' });
setMessage({ type: 'success', text: t('auth.signupSuccess') });
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Signup failed' });
}
@@ -52,12 +61,24 @@ const Login: React.FC<LoginProps> = () => {
setMessage(null);
try {
await api.post('users/verify_signup/', { username, code: verificationCode });
setMessage({ type: 'success', text: 'Account verified! You can now login.' });
setMessage({ type: 'success', text: t('auth.verifySuccess') });
setIsVerifying(false);
setTab(0);
setPassword('');
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Verification failed' });
setShowResend(true);
}
};
const handleResendCode = async () => {
setMessage(null);
try {
await api.post('users/resend_verification/', { username });
setMessage({ type: 'success', text: t('auth.resendSuccess') });
setShowResend(false);
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to resend code' });
}
};
@@ -93,20 +114,32 @@ const Login: React.FC<LoginProps> = () => {
if (isVerifying) {
return (
<form onSubmit={handleVerify}>
<Typography variant="h6" gutterBottom>Verify Account</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>Enter the code sent to {email}</Typography>
<Typography variant="h6" gutterBottom>{t('auth.verifyTitle')}</Typography>
<Typography variant="body2" sx={{ mb: 2 }} dangerouslySetInnerHTML={{ __html: t('auth.verifyDesc', { username }) }} />
<TextField
fullWidth
label="Verification Code"
margin="normal"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
autoFocus
/>
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
Verify
</Button>
<Button fullWidth onClick={() => setIsVerifying(false)} sx={{ mt: 1 }}>
Back to Signup
<Button
fullWidth
variant={showResend ? "contained" : "outlined"}
color={showResend ? "secondary" : "primary"}
onClick={handleResendCode}
sx={{ mt: 2, textTransform: 'none' }}
>
{t('auth.resendButton')}
</Button>
<Button fullWidth onClick={() => { setIsVerifying(false); setMessage(null); }} sx={{ mt: 1 }}>
{t('auth.backToAuth')}
</Button>
</form>
);
@@ -156,8 +189,8 @@ const Login: React.FC<LoginProps> = () => {
return (
<>
<Tabs value={tab} onChange={(_, v) => { setTab(v); setMessage(null); }} sx={{ mb: 2 }}>
<Tab label="Login" />
<Tab label="Signup" />
<Tab label={t('auth.loginTitle')} />
<Tab label={t('auth.signupTitle')} />
</Tabs>
{tab === 0 ? (
@@ -178,10 +211,10 @@ const Login: React.FC<LoginProps> = () => {
onChange={(e) => setPassword(e.target.value)}
/>
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
Login
{t('auth.loginTitle')}
</Button>
<Button fullWidth onClick={() => { setIsResetting(true); setMessage(null); }} sx={{ mt: 1, textTransform: 'none' }} size="small">
Forgot Password?
{t('auth.forgotPassword')}
</Button>
</form>
) : (
@@ -210,7 +243,7 @@ const Login: React.FC<LoginProps> = () => {
onChange={(e) => setPassword(e.target.value)}
/>
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
Signup
{t('auth.signupTitle')}
</Button>
</form>
)}
+99
View File
@@ -0,0 +1,99 @@
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate, Link } from 'react-router-dom';
import { Container, Paper, Typography, Box, CircularProgress, Button, Alert } from '@mui/material';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { useTranslation } from 'react-i18next';
import api from '../api';
import { useTitle } from '../hooks/useTitle';
const VerifyEmail: React.FC = () => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [errorMsg, setErrorMsg] = useState('');
useTitle(t('auth.verifyTitle'));
useEffect(() => {
const query = new URLSearchParams(location.search);
const username = query.get('username');
const code = query.get('code');
if (!username || !code) {
setStatus('error');
setErrorMsg('Invalid verification link.');
return;
}
const verify = async () => {
try {
await api.post('users/verify_signup/', { username, code });
setStatus('success');
// Optional: Auto redirect after 3 seconds
setTimeout(() => {
navigate('/login');
}, 3000);
} catch (err: any) {
setStatus('error');
setErrorMsg(err.response?.data?.error || 'Verification failed.');
}
};
verify();
}, [location, navigate]);
return (
<Container maxWidth="sm" sx={{ py: 12 }}>
<Paper sx={{ p: 6, textAlign: 'center', borderRadius: 4 }}>
{status === 'loading' && (
<Box>
<CircularProgress size={60} sx={{ mb: 4 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
Verifying your account...
</Typography>
<Typography color="text.secondary" sx={{ mt: 1 }}>
Please wait while we confirm your email.
</Typography>
</Box>
)}
{status === 'success' && (
<Box>
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}>
{t('auth.verifySuccess')}
</Typography>
<Typography color="text.secondary" sx={{ mb: 4 }}>
You will be redirected to the login page shortly.
</Typography>
<Button variant="contained" component={Link} to="/login" size="large" sx={{ borderRadius: 2 }}>
Go to Login Now
</Button>
</Box>
)}
{status === 'error' && (
<Box>
<ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}>
Verification Failed
</Typography>
<Alert severity="error" sx={{ mb: 4, textAlign: 'left' }}>
{errorMsg}
</Alert>
<Typography color="text.secondary" sx={{ mb: 4 }}>
The link might be expired or already used.
</Typography>
<Button variant="outlined" component={Link} to="/login" size="large" sx={{ borderRadius: 2 }}>
Back to Login / Signup
</Button>
</Box>
)}
</Paper>
</Container>
);
};
export default VerifyEmail;