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 */}
-
+ >
+ {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"