diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..27d6578
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -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"
diff --git a/backend/api/migrations/0027_alter_notification_notification_type.py b/backend/api/migrations/0027_alter_notification_notification_type.py
new file mode 100644
index 0000000..7208745
--- /dev/null
+++ b/backend/api/migrations/0027_alter_notification_notification_type.py
@@ -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),
+ ),
+ ]
diff --git a/backend/api/models.py b/backend/api/models.py
index a8b5559..9c8b5ac 100644
--- a/backend/api/models.py
+++ b/backend/api/models.py
@@ -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'),
diff --git a/backend/api/views.py b/backend/api/views.py
index 23fd3d8..b3598e6 100644
--- a/backend/api/views.py
+++ b/backend/api/views.py
@@ -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])
diff --git a/backend/holy_backend/settings.py b/backend/holy_backend/settings.py
index f5168d5..7ec54cf 100644
--- a/backend/holy_backend/settings.py
+++ b/backend/holy_backend/settings.py
@@ -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)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 72842b4..d8a3d81 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 = () => {
{n.actor_username} {
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 = () => {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index b71a800..3e9f089 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -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 {{username}}.",
+ "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 {{username}} 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",
diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx
index eeee4d1..dc7a515 100644
--- a/frontend/src/pages/CommunityFeed.tsx
+++ b/frontend/src/pages/CommunityFeed.tsx
@@ -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 = ({ adminMode }) => {
{notifications.length === 0 ? (
{t('community.noNotifications')}
) : (
- notifications.map(n => (
- n.flavor_id && navigate(`/flavor/${n.flavor_id}`)} sx={{ py: 1.5 }}>
-
- {n.actor_username.charAt(0)}
-
-
- {n.actor_username} {n.notification_type === 'reply' ? t('community.notifReply') : t('community.notifMention')}
-
- }
- secondary={formatTimestamp(n.created_at)}
- secondaryTypographyProps={{ sx: { fontSize: '0.7rem' } }}
- />
-
- ))
+ 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 (
+
+
+ {n.actor_username.charAt(0)}
+
+
+ {n.actor_username} {
+ n.notification_type === 'reply' ? t('community.notifReply') :
+ n.notification_type === 'mention' ? t('community.notifMention') :
+ n.notification_type === 'follow' ? t('community.notifFollow') :
+ n.notification_type
+ }
+
+ }
+ secondary={formatTimestamp(n.created_at)}
+ secondaryTypographyProps={{ sx: { fontSize: '0.7rem' } }}
+ />
+
+ );
+ })
)}
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index 0ca40d5..8273dfb 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -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 = () => {
+ const { t } = useTranslation();
useTitle('Login');
const [tab, setTab] = useState(0);
const [username, setUsername] = useState('');
@@ -16,6 +18,7 @@ const Login: React.FC = () => {
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 = () => {
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 = () => {
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 = () => {
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 = () => {
if (isVerifying) {
return (
);
@@ -156,8 +189,8 @@ const Login: React.FC = () => {
return (
<>
{ setTab(v); setMessage(null); }} sx={{ mb: 2 }}>
-
-
+
+
{tab === 0 ? (
@@ -178,10 +211,10 @@ const Login: React.FC = () => {
onChange={(e) => setPassword(e.target.value)}
/>
) : (
@@ -210,7 +243,7 @@ const Login: React.FC = () => {
onChange={(e) => setPassword(e.target.value)}
/>
)}
diff --git a/frontend/src/pages/VerifyEmail.tsx b/frontend/src/pages/VerifyEmail.tsx
new file mode 100644
index 0000000..d61b90d
--- /dev/null
+++ b/frontend/src/pages/VerifyEmail.tsx
@@ -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 (
+
+
+ {status === 'loading' && (
+
+
+
+ Verifying your account...
+
+
+ Please wait while we confirm your email.
+
+
+ )}
+
+ {status === 'success' && (
+
+
+
+ {t('auth.verifySuccess')}
+
+
+ You will be redirected to the login page shortly.
+
+
+
+ )}
+
+ {status === 'error' && (
+
+
+
+ Verification Failed
+
+
+ {errorMsg}
+
+
+ The link might be expired or already used.
+
+
+
+ )}
+
+
+ );
+};
+
+export default VerifyEmail;