From 0d38f81e2ca20a8f6b40e040e9d8c72ca5bb89a9 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Thu, 23 Oct 2025 02:06:14 +0000 Subject: [PATCH] feat(auth): implement custom Nextcloud OAuth with auto-provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced NextAuth's built-in OAuth provider (incompatible with Cloudflare Workers) with custom OAuth implementation using native fetch API. Features: - Custom OAuth flow compatible with Cloudflare Workers edge runtime - Auto-provisions users from Nextcloud based on group membership - Group-based role assignment (artists, shop_admins, admins) - Auto-creates artist profiles for users in 'artists' group - Seamless integration with existing NextAuth session management Technical changes: - Added custom OAuth routes: /api/auth/nextcloud/authorize & callback - Created Nextcloud API client for user provisioning (lib/nextcloud-client.ts) - Extended credentials provider to accept Nextcloud one-time tokens - Added user management functions to database layer - Updated signin UI to use custom OAuth flow - Added environment variables for OAuth configuration Documentation: - Comprehensive setup guide in docs/NEXTCLOUD-OAUTH-SETUP.md - Updated CLAUDE.md with new authentication architecture Fixes: NextAuth OAuth incompatibility with Cloudflare Workers (unenv https.request error) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .env.example | 20 ++ CLAUDE.md | 41 ++- app/api/auth/nextcloud/authorize/route.ts | 55 ++++ app/api/auth/nextcloud/callback/route.ts | 194 +++++++++++++ app/auth/nextcloud/complete/page.tsx | 58 ++++ app/auth/signin/page.tsx | 144 ++++++---- docs/NEXTCLOUD-OAUTH-SETUP.md | 317 ++++++++++++++++++++++ lib/auth.ts | 66 ++++- lib/db.ts | 147 +++++++++- lib/env.ts | 6 + lib/nextcloud-client.ts | 180 ++++++++++++ wrangler.toml | 2 + 12 files changed, 1162 insertions(+), 68 deletions(-) create mode 100644 app/api/auth/nextcloud/authorize/route.ts create mode 100644 app/api/auth/nextcloud/callback/route.ts create mode 100644 app/auth/nextcloud/complete/page.tsx create mode 100644 docs/NEXTCLOUD-OAUTH-SETUP.md create mode 100644 lib/nextcloud-client.ts diff --git a/.env.example b/.env.example index c43ec42e0..eb8bb3e10 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,26 @@ GOOGLE_CLIENT_SECRET="your-google-client-secret" GITHUB_CLIENT_ID="your-github-client-id" GITHUB_CLIENT_SECRET="your-github-client-secret" +# Nextcloud Configuration +# Nextcloud instance base URL +NEXTCLOUD_BASE_URL="https://portal.united-tattoos.com" + +# Nextcloud CalDAV Integration (Optional) +# Service account credentials for calendar sync +NEXTCLOUD_USERNAME="your-nextcloud-service-account" +NEXTCLOUD_PASSWORD="your-nextcloud-app-password" +NEXTCLOUD_CALENDAR_BASE_PATH="/remote.php/dav/calendars" + +# Nextcloud OAuth Authentication +# OAuth app credentials for artist authentication +NEXTCLOUD_OAUTH_CLIENT_ID="your-nextcloud-oauth-client-id" +NEXTCLOUD_OAUTH_CLIENT_SECRET="your-nextcloud-oauth-client-secret" + +# Nextcloud group name for auto-provisioning artists (default: "artists") +NEXTCLOUD_ARTISTS_GROUP="artists" +# Nextcloud group name for shop admins (default: "shop_admins") +NEXTCLOUD_ADMINS_GROUP="shop_admins" + # File Storage Configuration # AWS S3 or Cloudflare R2 for file uploads AWS_ACCESS_KEY_ID="your-aws-access-key-id" diff --git a/CLAUDE.md b/CLAUDE.md index b30f8aa8d..985965fde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,12 +100,34 @@ Main tables: ### Authentication (`lib/auth.ts`) -NextAuth.js setup with role-based access control: +NextAuth.js setup with role-based access control and Nextcloud OAuth integration: + +- **Primary Provider**: Nextcloud OAuth (recommended for all users) + - Artists and admins sign in via their Nextcloud credentials + - Auto-provisioning: Users in 'artists' or 'shop_admins' Nextcloud groups are automatically created + - Group-based role assignment: + - `admin` or `admins` group → SUPER_ADMIN + - `shop_admins` group (configurable) → SHOP_ADMIN + - `artists` group (configurable) → ARTIST (with auto-created artist profile) + - Users not in authorized groups are denied access + - Requires: `NEXTCLOUD_OAUTH_CLIENT_ID`, `NEXTCLOUD_OAUTH_CLIENT_SECRET`, `NEXTCLOUD_BASE_URL` + +- **Fallback Provider**: Credentials (email/password) + - Available only via `/auth/signin?admin=true` query parameter + - Admin emergency access only + - Dev mode: Any email/password combo creates a SUPER_ADMIN user for testing + - Seed admin: `nicholai@biohazardvfx.com` is hardcoded as admin + +- **Deprecated Providers**: Google/GitHub OAuth (still configured but not actively used) -- **Providers**: Credentials (email/password), optional Google/GitHub OAuth -- **Dev mode**: Any email/password combo creates a SUPER_ADMIN user for testing -- **Seed admin**: `nicholai@biohazardvfx.com` is hardcoded as admin - **Session strategy**: JWT (no database adapter currently) + +- **Nextcloud Integration** (`lib/nextcloud-client.ts`): + - `getNextcloudUserProfile(userId)` - Fetch user details from Nextcloud OCS API + - `getNextcloudUserGroups(userId)` - Get user's group memberships + - `determineUserRole(userId)` - Auto-assign role based on Nextcloud groups + - Uses service account credentials (NEXTCLOUD_USERNAME/PASSWORD) for API access + - **Helper functions**: - `requireAuth(role?)` - Protect routes, throws if unauthorized - `getArtistSession()` - Get artist profile for logged-in artist users @@ -140,7 +162,16 @@ Validates required environment variables at boot using Zod. Critical vars: - Database: `DATABASE_URL`, `DIRECT_URL` (Supabase URLs, though using D1) - Auth: `NEXTAUTH_URL`, `NEXTAUTH_SECRET` - Storage: AWS/R2 credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_BUCKET_NAME`, `AWS_ENDPOINT_URL`) -- CalDAV: Nextcloud credentials (optional) +- Nextcloud OAuth (required for artist authentication): + - `NEXTCLOUD_BASE_URL` - Nextcloud instance URL (e.g., https://portal.united-tattoos.com) + - `NEXTCLOUD_OAUTH_CLIENT_ID` - OAuth app client ID from Nextcloud + - `NEXTCLOUD_OAUTH_CLIENT_SECRET` - OAuth app client secret from Nextcloud + - `NEXTCLOUD_ARTISTS_GROUP` - Group name for artists (default: "artists") + - `NEXTCLOUD_ADMINS_GROUP` - Group name for admins (default: "shop_admins") +- Nextcloud CalDAV (optional, for calendar sync): + - `NEXTCLOUD_USERNAME` - Service account username + - `NEXTCLOUD_PASSWORD` - Service account password or app-specific password + - `NEXTCLOUD_CALENDAR_BASE_PATH` - CalDAV path (default: "/remote.php/dav/calendars") Note: The env validation expects Supabase URLs but actual runtime uses Cloudflare D1 via bindings. diff --git a/app/api/auth/nextcloud/authorize/route.ts b/app/api/auth/nextcloud/authorize/route.ts new file mode 100644 index 000000000..e80262b77 --- /dev/null +++ b/app/api/auth/nextcloud/authorize/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +/** + * Custom Nextcloud OAuth Authorization Handler + * + * This route initiates the OAuth flow by redirecting to Nextcloud's authorization endpoint. + * Uses native fetch API instead of NextAuth's OAuth provider (which doesn't work in Cloudflare Workers). + */ +export async function GET(request: NextRequest) { + const baseUrl = process.env.NEXTCLOUD_BASE_URL + const clientId = process.env.NEXTCLOUD_OAUTH_CLIENT_ID + + if (!baseUrl || !clientId) { + return NextResponse.json( + { error: 'Nextcloud OAuth is not configured' }, + { status: 500 } + ) + } + + // Get callback URL from request or use default + const callbackUrl = request.nextUrl.searchParams.get('callbackUrl') || '/admin' + + // Generate random state for CSRF protection + const state = crypto.randomUUID() + + // Store state and callback URL in cookies + const cookieStore = await cookies() + cookieStore.set('nextcloud_oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 600, // 10 minutes + path: '/', + }) + + cookieStore.set('nextcloud_oauth_callback', callbackUrl, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 600, + path: '/', + }) + + // Build authorization URL + const authUrl = new URL(`${baseUrl}/index.php/apps/oauth2/authorize`) + authUrl.searchParams.set('client_id', clientId) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('redirect_uri', `${process.env.NEXTAUTH_URL}/api/auth/nextcloud/callback`) + authUrl.searchParams.set('state', state) + authUrl.searchParams.set('scope', 'openid profile email') + + // Redirect to Nextcloud + return NextResponse.redirect(authUrl.toString()) +} diff --git a/app/api/auth/nextcloud/callback/route.ts b/app/api/auth/nextcloud/callback/route.ts new file mode 100644 index 000000000..d81f7c9a8 --- /dev/null +++ b/app/api/auth/nextcloud/callback/route.ts @@ -0,0 +1,194 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { signIn } from 'next-auth/react' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { determineUserRole, getNextcloudUserProfile } from '@/lib/nextcloud-client' +import { getUserByEmail, createUser, createArtist } from '@/lib/db' +import { UserRole } from '@/types/database' + +/** + * Custom Nextcloud OAuth Callback Handler + * + * Handles the OAuth callback from Nextcloud, exchanges code for token, + * fetches user info, auto-provisions users/artists, and creates a session. + */ +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams + const code = searchParams.get('code') + const state = searchParams.get('state') + const error = searchParams.get('error') + + // Handle OAuth errors + if (error) { + console.error('OAuth error:', error) + return NextResponse.redirect( + `${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin` + ) + } + + if (!code || !state) { + return NextResponse.redirect( + `${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin` + ) + } + + // Validate state (CSRF protection) + const cookieStore = await cookies() + const storedState = cookieStore.get('nextcloud_oauth_state')?.value + const callbackUrl = cookieStore.get('nextcloud_oauth_callback')?.value || '/admin' + + if (!storedState || storedState !== state) { + console.error('State mismatch - possible CSRF attack') + return NextResponse.redirect( + `${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin` + ) + } + + // Clear state cookies + cookieStore.delete('nextcloud_oauth_state') + cookieStore.delete('nextcloud_oauth_callback') + + const baseUrl = process.env.NEXTCLOUD_BASE_URL + const clientId = process.env.NEXTCLOUD_OAUTH_CLIENT_ID + const clientSecret = process.env.NEXTCLOUD_OAUTH_CLIENT_SECRET + + if (!baseUrl || !clientId || !clientSecret) { + return NextResponse.json( + { error: 'Nextcloud OAuth is not configured' }, + { status: 500 } + ) + } + + try { + // Step 1: Exchange authorization code for access token + const tokenUrl = `${baseUrl}/index.php/apps/oauth2/api/v1/token` + const tokenResponse = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: `${process.env.NEXTAUTH_URL}/api/auth/nextcloud/callback`, + client_id: clientId, + client_secret: clientSecret, + }), + }) + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text() + console.error('Token exchange failed:', errorText) + return NextResponse.redirect( + `${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin` + ) + } + + const tokenData = await tokenResponse.json() + const accessToken = tokenData.access_token + + // Step 2: Fetch user info from Nextcloud + const userInfoUrl = `${baseUrl}/ocs/v2.php/cloud/user?format=json` + const userInfoResponse = await fetch(userInfoUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'OCS-APIRequest': 'true', + }, + }) + + if (!userInfoResponse.ok) { + console.error('Failed to fetch user info') + return NextResponse.redirect( + `${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin` + ) + } + + const userInfoData = await userInfoResponse.json() + const userData = userInfoData.ocs?.data + + if (!userData) { + console.error('Invalid user info response') + return NextResponse.redirect( + `${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin` + ) + } + + const userId = userData.id + const email = userData.email + const displayName = userData.displayname || userData['display-name'] || userId + + console.log(`Nextcloud user authenticated: ${email} (${userId})`) + + // Step 3: Determine role from Nextcloud groups + const role = await determineUserRole(userId) + + // Prevent non-authorized users from signing in + if (role === UserRole.CLIENT) { + console.warn(`User ${email} is not in an authorized group`) + return NextResponse.redirect( + `${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin` + ) + } + + // Step 4: Auto-provision user if needed + let user = await getUserByEmail(email) + + if (!user) { + console.log(`Creating new user for ${email} with role ${role}`) + + user = await createUser({ + email, + name: displayName, + role, + }) + + // If artist, create artist profile + if (role === UserRole.ARTIST) { + const artist = await createArtist({ + userId: user.id, + name: displayName, + bio: '', + specialties: [], + instagramHandle: null, + hourlyRate: null, + isActive: true, + }) + + console.log(`Created artist profile ${artist.id}`) + } + } else { + console.log(`Existing user ${email} signed in`) + } + + // Step 5: Create a one-time token for session completion + // Store user ID in a short-lived cookie that credentials provider will validate + const oneTimeToken = crypto.randomUUID() + + cookieStore.set('nextcloud_user_id', user.id, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60, // 1 minute - just long enough to complete sign-in + path: '/', + }) + + cookieStore.set('nextcloud_one_time_token', oneTimeToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60, + path: '/', + }) + + // Redirect to completion page that will auto-submit to NextAuth + return NextResponse.redirect( + `${process.env.NEXTAUTH_URL}/auth/nextcloud/complete?token=${oneTimeToken}&callbackUrl=${encodeURIComponent(callbackUrl)}` + ) + } catch (error) { + console.error('OAuth callback error:', error) + return NextResponse.redirect( + `${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin` + ) + } +} diff --git a/app/auth/nextcloud/complete/page.tsx b/app/auth/nextcloud/complete/page.tsx new file mode 100644 index 000000000..395d99cca --- /dev/null +++ b/app/auth/nextcloud/complete/page.tsx @@ -0,0 +1,58 @@ +"use client" + +import { useEffect } from "react" +import { signIn } from "next-auth/react" +import { useSearchParams } from "next/navigation" +import { Loader2 } from "lucide-react" + +/** + * Nextcloud OAuth Completion Page + * + * This page automatically completes the NextAuth sign-in after successful OAuth. + * It receives a one-time token and submits it to the credentials provider. + */ +export default function NextcloudCompletePage() { + const searchParams = useSearchParams() + const token = searchParams.get("token") + const callbackUrl = searchParams.get("callbackUrl") || "/admin" + + useEffect(() => { + if (!token) { + // No token, redirect to sign-in + window.location.href = "/auth/signin?error=SessionError" + return + } + + // Auto-submit to NextAuth credentials provider + const completeSignIn = async () => { + try { + const result = await signIn("credentials", { + nextcloud_token: token, + redirect: false, + }) + + if (result?.error) { + console.error("Sign-in error:", result.error) + window.location.href = "/auth/signin?error=SessionError" + } else if (result?.ok) { + // Success! Redirect to callback URL + window.location.href = callbackUrl + } + } catch (error) { + console.error("Unexpected error during sign-in:", error) + window.location.href = "/auth/signin?error=SessionError" + } + } + + completeSignIn() + }, [token, callbackUrl]) + + return ( +
+
+ +

Completing sign-in...

+
+
+ ) +} diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index 8487a3da4..d4f6c6892 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -8,16 +8,17 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Alert, AlertDescription } from "@/components/ui/alert" -import { Loader2 } from "lucide-react" +import { Loader2, Cloud } from "lucide-react" export default function SignInPage() { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const searchParams = useSearchParams() const router = useRouter() - + const urlError = searchParams.get("error") const callbackUrl = searchParams.get("callbackUrl") || "/admin" + const showAdminLogin = searchParams.get("admin") === "true" const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -49,71 +50,116 @@ export default function SignInPage() { } } + const handleNextcloudSignIn = () => { + setIsLoading(true) + setError(null) + // Redirect to custom OAuth authorization route + window.location.href = `/api/auth/nextcloud/authorize?callbackUrl=${encodeURIComponent(callbackUrl)}` + } + return (
Sign In - Access the United Tattoo Studio admin dashboard + {showAdminLogin + ? "Admin emergency access" + : "Access the United Tattoo Studio dashboard"} {(error || urlError) && ( - {error || (urlError === "CredentialsSignin" - ? "Invalid email or password. Please try again." - : "An error occurred during sign in. Please try again." - )} + {error || + (urlError === "CredentialsSignin" + ? "Invalid email or password. Please try again." + : urlError === "OAuthSignin" + ? "Unable to sign in with Nextcloud. Please ensure you are in the 'artists' or 'shop_admins' group." + : "An error occurred during sign in. Please try again.")} )} - {/* Credentials Form */} -
-
- - + {/* Nextcloud OAuth Primary Sign In */} +
-
- - -
- -
+ > + {isLoading ? ( + <> + + Signing in... + + ) : ( + <> + + Sign in with Nextcloud + + )} + - {/* Development Note */} -
-

For development testing:

-

- Use any email/password combination.
- Admin: nicholai@biohazardvfx.com -

-
+ {/* Info Text */} +
+

Use your Nextcloud credentials to sign in.

+

+ You must be a member of the 'artists' or + 'shop_admins' group. +

+
+ + ) : ( + <> + {/* Credentials Form (Admin Fallback) */} +
+
+ + +
+
+ + +
+ +
+ + {/* Development Note */} +
+

+ Admin emergency access only.
+ For normal sign in, use Nextcloud OAuth. +

+
+ + )}
diff --git a/docs/NEXTCLOUD-OAUTH-SETUP.md b/docs/NEXTCLOUD-OAUTH-SETUP.md new file mode 100644 index 000000000..0653513ec --- /dev/null +++ b/docs/NEXTCLOUD-OAUTH-SETUP.md @@ -0,0 +1,317 @@ +# Nextcloud OAuth Authentication Setup Guide + +This guide explains how to set up Nextcloud OAuth authentication for United Tattoo Studio and migrate existing artist accounts. + +## Table of Contents + +1. [Nextcloud OAuth App Registration](#nextcloud-oauth-app-registration) +2. [Environment Configuration](#environment-configuration) +3. [Nextcloud Group Setup](#nextcloud-group-setup) +4. [Migrating Existing Artists](#migrating-existing-artists) +5. [Testing the Integration](#testing-the-integration) +6. [Troubleshooting](#troubleshooting) + +--- + +## Nextcloud OAuth App Registration + +### Step 1: Access OAuth Settings in Nextcloud + +1. Log in to your Nextcloud instance as an administrator: https://portal.united-tattoos.com +2. Navigate to **Settings** → **Security** → **OAuth 2.0 clients** (bottom of the page) + +### Step 2: Create New OAuth App + +1. Click **"Add client"** +2. Fill in the following details: + - **Name**: `United Tattoo Studio` (or any descriptive name) + - **Redirection URI**: `https://united-tattoos.com/api/auth/callback/nextcloud` + - For local development: `http://localhost:3000/api/auth/callback/nextcloud` + - For preview/staging: `https://your-preview-url.pages.dev/api/auth/callback/nextcloud` +3. Click **"Add"** + +### Step 3: Save Credentials + +After creating the OAuth app, Nextcloud will display: +- **Client Identifier** (Client ID) +- **Secret** (Client Secret) + +**IMPORTANT**: Copy these values immediately and store them securely. The secret will not be shown again. + +--- + +## Environment Configuration + +### Step 1: Update Environment Variables + +Add the following variables to your `.env.local` file (or production environment): + +```bash +# Nextcloud Configuration +NEXTCLOUD_BASE_URL="https://portal.united-tattoos.com" + +# Nextcloud OAuth Authentication +NEXTCLOUD_OAUTH_CLIENT_ID="your-client-id-from-step-3" +NEXTCLOUD_OAUTH_CLIENT_SECRET="your-client-secret-from-step-3" + +# Group names for auto-provisioning (customize if needed) +NEXTCLOUD_ARTISTS_GROUP="artists" +NEXTCLOUD_ADMINS_GROUP="shop_admins" + +# Nextcloud CalDAV Integration (existing, for calendar sync) +NEXTCLOUD_USERNAME="your-service-account-username" +NEXTCLOUD_PASSWORD="your-service-account-app-password" +NEXTCLOUD_CALENDAR_BASE_PATH="/remote.php/dav/calendars" +``` + +### Step 2: Verify Configuration + +Run the following command to check for configuration errors: + +```bash +npm run build +``` + +If there are missing environment variables, the build will fail with a helpful error message from `lib/env.ts`. + +--- + +## Nextcloud Group Setup + +### Step 1: Create Required Groups + +1. In Nextcloud, navigate to **Settings** → **Users** +2. Click **"Add group"** and create the following groups: + - `artists` - For tattoo artists who need access to their portfolios + - `shop_admins` - For shop administrators + +### Step 2: Assign Users to Groups + +For each existing artist or admin: + +1. Go to **Settings** → **Users** +2. Find the user in the list +3. Click on their row and select the appropriate group(s): + - Artists: Add to `artists` group + - Shop admins: Add to `shop_admins` group + - Super admins: Add to both `shop_admins` AND `admins` (or `admin`) group + +**Note**: Users can be in multiple groups. For example, a shop owner who is also an artist should be in both `artists` and `shop_admins`. + +--- + +## Migrating Existing Artists + +### Understanding the Migration + +When an artist signs in via Nextcloud OAuth for the first time: + +1. The system checks if a user with that email already exists in the database +2. **If user exists**: The existing user account is linked to the Nextcloud OAuth session +3. **If user doesn't exist**: A new user and artist profile are auto-created based on Nextcloud group membership + +### Step 1: Match Email Addresses + +Ensure that each artist's email in Nextcloud matches their email in the United Tattoo database: + +```sql +-- Query to check existing artist emails in D1 database +SELECT u.email, a.name, a.slug +FROM users u +JOIN artists a ON u.id = a.user_id +WHERE u.role = 'ARTIST'; +``` + +Run this via: +```bash +wrangler d1 execute united-tattoo --command="SELECT u.email, a.name, a.slug FROM users u JOIN artists a ON u.id = a.user_id WHERE u.role = 'ARTIST';" +``` + +### Step 2: Create Nextcloud Accounts (If Needed) + +If an artist doesn't have a Nextcloud account yet: + +1. Go to **Settings** → **Users** in Nextcloud +2. Click **"New user"** +3. Fill in: + - **Username**: Artist's preferred username (e.g., `amari.kyss`) + - **Display name**: Artist's full name (e.g., "Amari Kyss") + - **Email**: **Must match** the email in the database + - **Groups**: Add to `artists` group +4. Set a temporary password and send it to the artist +5. Ask the artist to change their password on first login + +### Step 3: Notify Artists + +Send an email to all artists with the following information: + +**Email Template:** + +``` +Subject: New Login Process for United Tattoo Studio Dashboard + +Hello [Artist Name], + +We've updated the artist dashboard login process to use your Nextcloud account for easier access. + +What's Changed: +- You now sign in using your Nextcloud credentials (same account you use for [calendar/files/etc]) +- No need to remember a separate password for the artist dashboard +- More secure authentication via OAuth + +How to Sign In: +1. Go to https://united-tattoos.com/auth/signin +2. Click "Sign in with Nextcloud" +3. Use your Nextcloud username and password +4. You'll be redirected to your artist dashboard + +Your Nextcloud Credentials: +- Username: [their Nextcloud username] +- Email: [their email] +- If you forgot your Nextcloud password, you can reset it at: https://portal.united-tattoos.com/login + +Need Help? +Contact [admin contact] if you have any issues signing in. + +Thanks, +United Tattoo Studio Team +``` + +--- + +## Testing the Integration + +### Test 1: New Artist Sign In + +1. Ensure a test user is in the `artists` group in Nextcloud +2. Go to `/auth/signin` on your website +3. Click "Sign in with Nextcloud" +4. Authorize the OAuth app +5. Verify: + - User is created in the `users` table + - Artist profile is created in the `artists` table + - Redirect to `/artist-dashboard` works + - Artist can view/edit their profile + +### Test 2: Existing Artist Sign In + +1. Use an artist whose email matches an existing database record +2. Follow the same sign-in process +3. Verify: + - No duplicate user/artist created + - Existing artist profile is accessible + - Portfolio images are preserved + +### Test 3: Admin Sign In + +1. Ensure a test user is in the `shop_admins` group +2. Sign in via Nextcloud OAuth +3. Verify: + - User is created with `SHOP_ADMIN` role + - Redirect to `/admin` dashboard works + - Admin can access all admin features + +### Test 4: Unauthorized User + +1. Create a Nextcloud user NOT in any authorized group +2. Attempt to sign in via OAuth +3. Verify: + - Sign-in is **rejected** with an error message + - User is **not** created in the database + - Error message suggests joining the 'artists' or 'shop_admins' group + +### Test 5: Admin Fallback (Emergency Access) + +1. Go to `/auth/signin?admin=true` +2. Verify the credentials form is shown +3. Sign in with `nicholai@biohazardvfx.com` (or any email in dev mode) +4. Verify admin access works + +--- + +## Troubleshooting + +### Issue: "Unable to sign in with Nextcloud" + +**Possible causes:** +- User not in `artists` or `shop_admins` group +- OAuth app not configured correctly in Nextcloud +- Redirect URI mismatch + +**Solution:** +1. Check user's group membership in Nextcloud +2. Verify `NEXTCLOUD_OAUTH_CLIENT_ID` and `NEXTCLOUD_OAUTH_CLIENT_SECRET` are correct +3. Ensure redirect URI in Nextcloud matches your domain exactly + +### Issue: "Nextcloud API error" in server logs + +**Possible causes:** +- Service account credentials (`NEXTCLOUD_USERNAME`/`NEXTCLOUD_PASSWORD`) are incorrect +- Nextcloud OCS API is not accessible + +**Solution:** +1. Test service account credentials manually: + ```bash + curl -u "username:password" https://portal.united-tattoos.com/ocs/v1.php/cloud/users/testuser + ``` +2. Ensure the service account has admin privileges in Nextcloud +3. Check Nextcloud logs for any API access errors + +### Issue: Duplicate artist profiles created + +**Possible causes:** +- Email mismatch between Nextcloud and database +- User signed in before email was matched + +**Solution:** +1. Identify duplicate records: + ```sql + SELECT * FROM artists WHERE user_id IN ( + SELECT user_id FROM artists GROUP BY user_id HAVING COUNT(*) > 1 + ); + ``` +2. Manually merge duplicates by updating portfolio images to point to the correct artist +3. Delete the duplicate artist profile + +### Issue: Artist can't access dashboard after sign-in + +**Possible causes:** +- Artist profile not created during auto-provisioning +- Database transaction failed + +**Solution:** +1. Check if user exists: + ```sql + SELECT * FROM users WHERE email = 'artist@example.com'; + ``` +2. Check if artist profile exists: + ```sql + SELECT * FROM artists WHERE user_id = 'user-id-from-above'; + ``` +3. If user exists but artist doesn't, manually create artist: + ```sql + INSERT INTO artists (id, user_id, name, bio, specialties, is_active, slug) + VALUES ('uuid', 'user-id', 'Artist Name', '', '[]', 1, 'artist-name'); + ``` + +--- + +## Next Steps + +After completing this setup: + +1. **Monitor sign-ins**: Check server logs for any authentication errors +2. **Gather feedback**: Ask artists about their experience with the new login process +3. **Update documentation**: Keep this guide updated with any changes to the process +4. **Consider enhancements**: + - Sync artist profile photos from Nextcloud + - Enable calendar integration for all artists + - Add two-factor authentication requirement + +--- + +## Support + +For technical support or questions about this integration, contact the development team or file an issue in the project repository. + +Last updated: 2025-10-22 diff --git a/lib/auth.ts b/lib/auth.ts index da3964234..18bd0ca70 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -4,21 +4,76 @@ import GitHubProvider from "next-auth/providers/github" import CredentialsProvider from "next-auth/providers/credentials" import { env } from "./env" import { UserRole } from "@/types/database" +import { + getNextcloudUserProfile, + getNextcloudUserGroups, + determineUserRole +} from "./nextcloud-client" export const authOptions: NextAuthOptions = { // Note: Database adapter will be configured via Supabase MCP // For now, using JWT strategy without database adapter providers: [ - // Credentials provider for email/password login + // Credentials provider for email/password login (admin fallback) and Nextcloud OAuth completion CredentialsProvider({ name: "credentials", credentials: { email: { label: "Email", type: "email" }, - password: { label: "Password", type: "password" } + password: { label: "Password", type: "password" }, + nextcloud_token: { label: "Nextcloud Token", type: "text" }, }, - async authorize(credentials) { + async authorize(credentials, req) { console.log("Authorize called with:", credentials) - + + // Handle Nextcloud OAuth completion + if (credentials?.nextcloud_token) { + console.log("Nextcloud OAuth completion with token") + + // Get cookies from request + const cookies = req.headers?.cookie + if (!cookies) { + console.error("No cookies found") + return null + } + + // Parse cookies manually + const cookieMap = new Map( + cookies.split(';').map(c => { + const [key, ...values] = c.trim().split('=') + return [key, values.join('=')] + }) + ) + + const storedToken = cookieMap.get('nextcloud_one_time_token') + const userId = cookieMap.get('nextcloud_user_id') + + console.log("Stored token:", storedToken ? "present" : "missing") + console.log("User ID:", userId ? userId : "missing") + + if (!storedToken || !userId || storedToken !== credentials.nextcloud_token) { + console.error("Token validation failed") + return null + } + + // Fetch user from database + const { getUserById } = await import('@/lib/db') + const user = await getUserById(userId) + + if (!user) { + console.error("User not found") + return null + } + + console.log("Nextcloud user authenticated:", user.email) + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + } + } + + // Handle regular credentials login if (!credentials?.email || !credentials?.password) { console.log("Missing email or password") return null @@ -46,7 +101,7 @@ export const authOptions: NextAuthOptions = { name: credentials.email.split("@")[0], role: UserRole.SUPER_ADMIN, // Give admin access for testing } - + console.log("Created user:", user) return user } @@ -92,6 +147,7 @@ export const authOptions: NextAuthOptions = { }, async signIn({ user, account, profile }) { // Custom sign-in logic + // Note: Nextcloud OAuth auto-provisioning happens in custom callback handler return true }, async redirect({ url, baseUrl }) { diff --git a/lib/db.ts b/lib/db.ts index e6256b284..4c0717cf9 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,14 +1,16 @@ -import type { - Artist, - PortfolioImage, - Appointment, - SiteSettings, - CreateArtistInput, - UpdateArtistInput, - CreateAppointmentInput, +import type { + Artist, + PortfolioImage, + Appointment, + SiteSettings, + CreateArtistInput, + UpdateArtistInput, + CreateAppointmentInput, UpdateSiteSettingsInput, AppointmentFilters, - FlashItem + FlashItem, + User, + UserRole } from '@/types/database' // Type for Cloudflare D1 database binding @@ -37,6 +39,127 @@ export function getDB(env?: any): D1Database { return db as D1Database; } +/** + * User Management Functions + */ + +export async function getUserByEmail(email: string, env?: any): Promise { + const db = getDB(env); + const result = await db.prepare(` + SELECT * FROM users WHERE email = ? + `).bind(email).first(); + + if (!result) return null; + + return { + id: result.id as string, + email: result.email as string, + name: result.name as string, + role: result.role as UserRole, + avatar: result.avatar as string | undefined, + createdAt: new Date(result.created_at as string), + updatedAt: new Date(result.updated_at as string), + }; +} + +export async function getUserById(id: string, env?: any): Promise { + const db = getDB(env); + const result = await db.prepare(` + SELECT * FROM users WHERE id = ? + `).bind(id).first(); + + if (!result) return null; + + return { + id: result.id as string, + email: result.email as string, + name: result.name as string, + role: result.role as UserRole, + avatar: result.avatar as string | undefined, + createdAt: new Date(result.created_at as string), + updatedAt: new Date(result.updated_at as string), + }; +} + +export async function createUser(data: { + email: string + name: string + role: UserRole + avatar?: string +}, env?: any): Promise { + const db = getDB(env); + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + + await db.prepare(` + INSERT INTO users (id, email, name, role, avatar, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).bind( + id, + data.email, + data.name, + data.role, + data.avatar || null, + now, + now + ).run(); + + return { + id, + email: data.email, + name: data.name, + role: data.role, + avatar: data.avatar, + createdAt: new Date(now), + updatedAt: new Date(now), + }; +} + +export async function updateUser(id: string, data: { + email?: string + name?: string + role?: UserRole + avatar?: string +}, env?: any): Promise { + const db = getDB(env); + const now = new Date().toISOString(); + + const updates: string[] = []; + const values: any[] = []; + + if (data.email !== undefined) { + updates.push('email = ?'); + values.push(data.email); + } + if (data.name !== undefined) { + updates.push('name = ?'); + values.push(data.name); + } + if (data.role !== undefined) { + updates.push('role = ?'); + values.push(data.role); + } + if (data.avatar !== undefined) { + updates.push('avatar = ?'); + values.push(data.avatar); + } + + updates.push('updated_at = ?'); + values.push(now); + values.push(id); + + await db.prepare(` + UPDATE users SET ${updates.join(', ')} WHERE id = ? + `).bind(...values).run(); + + const updated = await getUserById(id, env); + if (!updated) { + throw new Error(`Failed to update user ${id}`); + } + + return updated; +} + /** * Artist Management Functions */ @@ -705,6 +828,12 @@ export async function updateSiteSettings(data: UpdateSiteSettingsInput, env?: an // Type-safe query builder helpers export const db = { + users: { + findByEmail: getUserByEmail, + findById: getUserById, + create: createUser, + update: updateUser, + }, artists: { findMany: getArtists, findUnique: getArtist, diff --git a/lib/env.ts b/lib/env.ts index 6c26f90d0..383879798 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -39,6 +39,12 @@ const envSchema = z.object({ NEXTCLOUD_USERNAME: z.string().optional(), NEXTCLOUD_PASSWORD: z.string().optional(), NEXTCLOUD_CALENDAR_BASE_PATH: z.string().default('/remote.php/dav/calendars'), + + // Nextcloud OAuth Authentication + NEXTCLOUD_OAUTH_CLIENT_ID: z.string().optional(), + NEXTCLOUD_OAUTH_CLIENT_SECRET: z.string().optional(), + NEXTCLOUD_ARTISTS_GROUP: z.string().default('artists'), + NEXTCLOUD_ADMINS_GROUP: z.string().default('shop_admins'), }) export type Env = z.infer diff --git a/lib/nextcloud-client.ts b/lib/nextcloud-client.ts new file mode 100644 index 000000000..12a7831fc --- /dev/null +++ b/lib/nextcloud-client.ts @@ -0,0 +1,180 @@ +/** + * Nextcloud API Client + * + * Provides functions to interact with Nextcloud OCS (Open Collaboration Services) API + * for user management and group membership checking during OAuth authentication. + * + * API Documentation: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/index.html + */ + +interface NextcloudUserProfile { + id: string + enabled: boolean + email: string + displayname: string + groups: string[] + quota?: { + free: number + used: number + total: number + relative: number + quota: number + } +} + +interface NextcloudOCSResponse { + ocs: { + meta: { + status: string + statuscode: number + message: string + } + data: T + } +} + +/** + * Get authenticated user's profile from Nextcloud + * Uses OCS API with Basic Auth (service account credentials) + * + * @param userId Nextcloud user ID + * @returns User profile including groups, email, and display name + */ +export async function getNextcloudUserProfile( + userId: string +): Promise { + const baseUrl = process.env.NEXTCLOUD_BASE_URL + const username = process.env.NEXTCLOUD_USERNAME + const password = process.env.NEXTCLOUD_PASSWORD + + if (!baseUrl || !username || !password) { + console.error('Nextcloud credentials not configured for user API access') + return null + } + + try { + const url = `${baseUrl}/ocs/v1.php/cloud/users/${encodeURIComponent(userId)}` + const auth = Buffer.from(`${username}:${password}`).toString('base64') + + const response = await fetch(url, { + headers: { + 'Authorization': `Basic ${auth}`, + 'OCS-APIRequest': 'true', + 'Accept': 'application/json', + }, + }) + + if (!response.ok) { + console.error(`Failed to fetch Nextcloud user profile: ${response.status} ${response.statusText}`) + return null + } + + const data = await response.json() as NextcloudOCSResponse + + if (data.ocs.meta.statuscode !== 100) { + console.error(`Nextcloud API error: ${data.ocs.meta.message}`) + return null + } + + return data.ocs.data + } catch (error) { + console.error('Error fetching Nextcloud user profile:', error) + return null + } +} + +/** + * Get user's group memberships from Nextcloud + * + * @param userId Nextcloud user ID + * @returns Array of group names the user belongs to + */ +export async function getNextcloudUserGroups( + userId: string +): Promise { + const baseUrl = process.env.NEXTCLOUD_BASE_URL + const username = process.env.NEXTCLOUD_USERNAME + const password = process.env.NEXTCLOUD_PASSWORD + + if (!baseUrl || !username || !password) { + console.error('Nextcloud credentials not configured for group API access') + return [] + } + + try { + const url = `${baseUrl}/ocs/v1.php/cloud/users/${encodeURIComponent(userId)}/groups` + const auth = Buffer.from(`${username}:${password}`).toString('base64') + + const response = await fetch(url, { + headers: { + 'Authorization': `Basic ${auth}`, + 'OCS-APIRequest': 'true', + 'Accept': 'application/json', + }, + }) + + if (!response.ok) { + console.error(`Failed to fetch Nextcloud user groups: ${response.status} ${response.statusText}`) + return [] + } + + const data = await response.json() as NextcloudOCSResponse<{ groups: string[] }> + + if (data.ocs.meta.statuscode !== 100) { + console.error(`Nextcloud API error: ${data.ocs.meta.message}`) + return [] + } + + return data.ocs.data.groups + } catch (error) { + console.error('Error fetching Nextcloud user groups:', error) + return [] + } +} + +/** + * Check if a user belongs to a specific group in Nextcloud + * + * @param userId Nextcloud user ID + * @param groupName Group name to check + * @returns True if user is in the group, false otherwise + */ +export async function isUserInGroup( + userId: string, + groupName: string +): Promise { + const groups = await getNextcloudUserGroups(userId) + return groups.includes(groupName) +} + +/** + * Determine the appropriate role for a user based on their Nextcloud group memberships + * + * @param userId Nextcloud user ID + * @returns Role: 'SUPER_ADMIN', 'SHOP_ADMIN', 'ARTIST', or 'CLIENT' + */ +export async function determineUserRole( + userId: string +): Promise<'SUPER_ADMIN' | 'SHOP_ADMIN' | 'ARTIST' | 'CLIENT'> { + const groups = await getNextcloudUserGroups(userId) + + const adminsGroup = process.env.NEXTCLOUD_ADMINS_GROUP || 'shop_admins' + const artistsGroup = process.env.NEXTCLOUD_ARTISTS_GROUP || 'artists' + + // Check for admin groups first (higher priority) + if (groups.includes('admin') || groups.includes('admins')) { + return 'SUPER_ADMIN' + } + + if (groups.includes(adminsGroup)) { + return 'SHOP_ADMIN' + } + + // Check for artist group + if (groups.includes(artistsGroup)) { + return 'ARTIST' + } + + // Default to client role + return 'CLIENT' +} diff --git a/wrangler.toml b/wrangler.toml index c4011f3ef..988a9fcdf 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -6,6 +6,8 @@ main = ".open-next/worker.js" [vars] MIGRATE_TOKEN = "ut_migrate_20251006_rotated_1a2b3c" +NEXTAUTH_URL = "https://united-tattoos.com" +NODE_ENV = "production" [assets] directory = ".open-next/assets"