Skip to content

Commit 6cb4c6d

Browse files
feat: WIP solution for authentication (issue #24)
1 parent 01f36c4 commit 6cb4c6d

File tree

16 files changed

+1197
-7
lines changed

16 files changed

+1197
-7
lines changed

AUTHENTICATION_SETUP.md

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Authentication Setup Guide
2+
3+
This guide will help you set up Appwrite authentication for your Practice Exams Platform.
4+
5+
## Prerequisites
6+
7+
1. An Appwrite account (sign up at [appwrite.io](https://appwrite.io))
8+
2. A new Appwrite project
9+
10+
## Step 1: Create Appwrite Project
11+
12+
1. Log in to your Appwrite console
13+
2. Click "Create Project"
14+
3. Give your project a name (e.g., "Practice Exams Platform")
15+
4. Choose your preferred region
16+
5. Click "Create"
17+
18+
## Step 2: Configure Authentication
19+
20+
### Enable Authentication Methods
21+
22+
1. In your project dashboard, go to **Auth****Settings**
23+
2. Enable the following authentication methods:
24+
- **Email/Password** (for OTP)
25+
- **Google OAuth**
26+
- **Apple OAuth**
27+
28+
### Configure OAuth Providers
29+
30+
#### Google OAuth
31+
32+
1. Go to **Auth****OAuth2 Providers**
33+
2. Click on **Google**
34+
3. Enable the provider
35+
4. Add your Google OAuth credentials:
36+
- Client ID
37+
- Client Secret
38+
5. Set redirect URL: `https://yourdomain.com/auth/callback`
39+
40+
#### Apple OAuth
41+
42+
1. Go to **Auth****OAuth2 Providers**
43+
2. Click on **Apple**
44+
3. Enable the provider
45+
4. Add your Apple OAuth credentials:
46+
- Client ID
47+
- Client Secret
48+
- Team ID
49+
5. Set redirect URL: `https://yourdomain.com/auth/callback`
50+
51+
### Configure Email Templates
52+
53+
1. Go to **Auth****Templates**
54+
2. Customize the email templates for:
55+
- Magic URL (email OTP)
56+
- Email verification
57+
58+
## Step 3: Environment Variables
59+
60+
Create a `.env.local` file in your project root with:
61+
62+
```bash
63+
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
64+
NEXT_PUBLIC_APPWRITE_PROJECT_ID=your_project_id_here
65+
```
66+
67+
Replace `your_project_id_here` with your actual Appwrite project ID.
68+
69+
## Step 4: Update Callback URLs
70+
71+
In your Appwrite project settings, add these callback URLs:
72+
73+
- **Success URL**: `https://yourdomain.com/auth/callback?success=true`
74+
- **Failure URL**: `https://yourdomain.com/auth/callback?failure=true`
75+
76+
## Step 5: Test Authentication
77+
78+
1. Start your development server
79+
2. Navigate to any practice or exam page
80+
3. You should see the 15-minute trial timer
81+
4. After 15 minutes, the authentication modal should appear
82+
5. Test all three authentication methods:
83+
- Email OTP
84+
- Google OAuth
85+
- Apple OAuth
86+
87+
## Features Implemented
88+
89+
### Authentication Methods
90+
91+
- **Email OTP**: Magic link authentication via email
92+
- **Google OAuth**: Sign in with Google account
93+
- **Apple OAuth**: Sign in with Apple ID
94+
95+
### Trial System
96+
97+
- **15-minute trial** for unauthenticated users
98+
- **Automatic blocking** after trial expires
99+
- **Persistent trial state** across browser sessions
100+
- **Visual indicators** for trial status
101+
102+
### User Experience
103+
104+
- **Seamless integration** with existing UI
105+
- **Responsive design** for mobile and desktop
106+
- **User profile management** in navigation
107+
- **Automatic redirects** after authentication
108+
109+
## PWA Compatibility
110+
111+
This authentication system is fully compatible with PWA Builder for:
112+
113+
- **Android** deployment
114+
- **iOS** deployment
115+
- **Microsoft Store** deployment
116+
117+
The authentication flow works seamlessly across all platforms.
118+
119+
## Troubleshooting
120+
121+
### Common Issues
122+
123+
1. **OAuth redirect errors**: Ensure callback URLs are correctly configured
124+
2. **Email not sending**: Check Appwrite email service configuration
125+
3. **Trial timer not working**: Clear localStorage and refresh page
126+
4. **Authentication state not persisting**: Check browser console for errors
127+
128+
### Debug Mode
129+
130+
Enable debug logging by adding to your `.env.local`:
131+
132+
```bash
133+
NEXT_PUBLIC_DEBUG_AUTH=true
134+
```
135+
136+
## Security Considerations
137+
138+
- All authentication is handled server-side by Appwrite
139+
- No sensitive credentials are stored in the frontend
140+
- Session management is handled securely by Appwrite
141+
- OAuth tokens are never exposed to the client
142+
143+
## Next Steps
144+
145+
After setup, consider:
146+
147+
1. Adding user profile management
148+
2. Implementing role-based access control
149+
3. Adding analytics for user engagement
150+
4. Setting up email notifications for user actions

app/auth/callback/page.tsx

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { useRouter, useSearchParams } from "next/navigation";
5+
import { AuthService } from "../../../lib/appwrite/auth";
6+
import LoadingIndicator from "../../../components/LoadingIndicator";
7+
8+
export default function AuthCallback() {
9+
const router = useRouter();
10+
const searchParams = useSearchParams();
11+
const [status, setStatus] = useState<"loading" | "success" | "error">(
12+
"loading",
13+
);
14+
const [message, setMessage] = useState("");
15+
16+
useEffect(() => {
17+
const handleCallback = async () => {
18+
try {
19+
// Check if this is an OAuth callback
20+
const userId = searchParams.get("userId");
21+
const secret = searchParams.get("secret");
22+
const success = searchParams.get("success");
23+
const failure = searchParams.get("failure");
24+
25+
if (failure) {
26+
setStatus("error");
27+
setMessage("Authentication failed. Please try again.");
28+
setTimeout(() => router.push("/"), 3000);
29+
return;
30+
}
31+
32+
if (success === "true") {
33+
setStatus("success");
34+
setMessage("Authentication successful! Redirecting...");
35+
setTimeout(() => router.push("/"), 2000);
36+
return;
37+
}
38+
39+
// Handle magic link callback
40+
if (userId && secret) {
41+
const result = await AuthService.updateEmailSession(userId, secret);
42+
if (result.success) {
43+
setStatus("success");
44+
setMessage("Email verified successfully! Redirecting...");
45+
setTimeout(() => router.push("/"), 2000);
46+
} else {
47+
setStatus("error");
48+
setMessage(result.error?.message || "Verification failed");
49+
setTimeout(() => router.push("/"), 3000);
50+
}
51+
return;
52+
}
53+
54+
// If no callback parameters, redirect to home
55+
router.push("/");
56+
} catch (error) {
57+
setStatus("error");
58+
setMessage("An error occurred during authentication");
59+
setTimeout(() => router.push("/"), 3000);
60+
}
61+
};
62+
63+
handleCallback();
64+
}, [router, searchParams]);
65+
66+
if (status === "loading") {
67+
return (
68+
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
69+
<div className="text-center">
70+
<LoadingIndicator />
71+
<p className="text-white mt-4">Processing authentication...</p>
72+
</div>
73+
</div>
74+
);
75+
}
76+
77+
return (
78+
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
79+
<div className="text-center">
80+
{status === "success" ? (
81+
<div className="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4">
82+
<svg
83+
className="w-8 h-8 text-white"
84+
fill="none"
85+
stroke="currentColor"
86+
viewBox="0 0 24 24"
87+
>
88+
<path
89+
strokeLinecap="round"
90+
strokeLinejoin="round"
91+
strokeWidth={2}
92+
d="M5 13l4 4L19 7"
93+
/>
94+
</svg>
95+
</div>
96+
) : (
97+
<div className="w-16 h-16 bg-red-500 rounded-full flex items-center justify-center mx-auto mb-4">
98+
<svg
99+
className="w-8 h-8 text-white"
100+
fill="none"
101+
stroke="currentColor"
102+
viewBox="0 0 24 24"
103+
>
104+
<path
105+
strokeLinecap="round"
106+
strokeLinejoin="round"
107+
strokeWidth={2}
108+
d="M6 18L18 6M6 6l12 12"
109+
/>
110+
</svg>
111+
</div>
112+
)}
113+
114+
<h2 className="text-2xl font-bold text-white mb-2">
115+
{status === "success" ? "Success!" : "Error"}
116+
</h2>
117+
<p className="text-slate-300 mb-4">{message}</p>
118+
119+
<button
120+
onClick={() => router.push("/")}
121+
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors"
122+
>
123+
Go to Home
124+
</button>
125+
</div>
126+
</div>
127+
);
128+
}

app/exam/page.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import QuizExamForm from "@azure-fundamentals/components/QuizExamFormUF";
99
import { Question } from "@azure-fundamentals/components/types";
1010
import ExamResult from "@azure-fundamentals/components/ExamResult";
1111
import LoadingIndicator from "@azure-fundamentals/components/LoadingIndicator";
12+
import { useTrialAccess } from "@azure-fundamentals/hooks/useTrialAccess";
1213

1314
const questionsQuery = gql`
1415
query RandomQuestions($range: Int!, $link: String) {
@@ -29,6 +30,7 @@ const questionsQuery = gql`
2930
const Exam: NextPage<{ searchParams: { url: string; name: string } }> = ({
3031
searchParams,
3132
}) => {
33+
const { isAccessBlocked, isInTrial } = useTrialAccess();
3234
const { url } = searchParams;
3335
const { minutes, seconds } = {
3436
minutes: 15,
@@ -87,13 +89,57 @@ const Exam: NextPage<{ searchParams: { url: string; name: string } }> = ({
8789
setCurrentQuestion(data?.randomQuestions[0]);
8890
}, [data]);
8991

92+
// Show loading while checking trial access
93+
if (isAccessBlocked === undefined) {
94+
return <LoadingIndicator />;
95+
}
96+
97+
// Block access if trial expired
98+
if (isAccessBlocked) {
99+
return (
100+
<div className="py-10 px-5 mb-6 mx-auto w-[90vw] lg:w-[60vw] 2xl:w-[45%] bg-slate-800 border-2 border-slate-700 rounded-lg text-center">
101+
<div className="text-red-400 text-lg mb-4">
102+
⏰ Trial expired. Please sign in to continue taking exams.
103+
</div>
104+
<button
105+
onClick={() => (window.location.href = "/")}
106+
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors"
107+
>
108+
Go to Home
109+
</button>
110+
</div>
111+
);
112+
}
113+
90114
if (loading) return <LoadingIndicator />;
91115
if (error) return <p>Oh no... {error.message}</p>;
92116

93117
const numberOfQuestions = data.randomQuestions.length || 0;
94118

95119
return (
96120
<div className="py-10 px-5 mb-6 mx-auto w-[90vw] lg:w-[60vw] 2xl:w-[45%] bg-slate-800 border-2 border-slate-700 rounded-lg">
121+
{isInTrial && (
122+
<div className="mb-6 p-4 bg-amber-600/20 border border-amber-600/40 rounded-lg">
123+
<div className="flex items-center gap-2 text-amber-300">
124+
<svg
125+
className="w-5 h-5"
126+
fill="none"
127+
stroke="currentColor"
128+
viewBox="0 0 24 24"
129+
>
130+
<path
131+
strokeLinecap="round"
132+
strokeLinejoin="round"
133+
strokeWidth={2}
134+
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
135+
/>
136+
</svg>
137+
<span className="font-medium">
138+
Trial Mode - Sign in to unlock unlimited access
139+
</span>
140+
</div>
141+
</div>
142+
)}
97143
<div>
98144
<div className="px-2 sm:px-10 w-full flex flex-row justify-between items-center">
99145
<p className="text-white font-bold text-sm sm:text-2xl">

app/layout.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import TopNav from "@azure-fundamentals/components/TopNav";
44
import Footer from "@azure-fundamentals/components/Footer";
55
import ApolloProvider from "@azure-fundamentals/components/ApolloProvider";
66
import Cookie from "@azure-fundamentals/components/Cookie";
7+
import { AuthProvider } from "@azure-fundamentals/contexts/AuthContext";
8+
import { TrialWarning } from "@azure-fundamentals/components/TrialWarning";
79
import "styles/globals.css";
810

911
export const viewport: Viewport = {
@@ -106,12 +108,15 @@ export default function RootLayout({ children }: RootLayoutProps) {
106108
<html lang="en">
107109
<body className="bg-slate-900">
108110
<ApolloProvider>
109-
<TopNav />
110-
<main className="flex flex-col justify-between md:h-[calc(100vh-2.5rem-64px)] h-full">
111-
{children}
112-
<Footer />
113-
<Cookie />
114-
</main>
111+
<AuthProvider>
112+
<TopNav />
113+
<main className="flex flex-col justify-between md:h-[calc(100vh-2.5rem-64px)] h-full">
114+
{children}
115+
<Footer />
116+
<Cookie />
117+
<TrialWarning />
118+
</main>
119+
</AuthProvider>
115120
</ApolloProvider>
116121
</body>
117122
</html>

0 commit comments

Comments
 (0)