feat(auth): implement custom Nextcloud OAuth with auto-provisioning

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 <noreply@anthropic.com>
This commit is contained in:
Nicholai 2025-10-23 02:06:14 +00:00
parent 3614271881
commit 0d38f81e2c
12 changed files with 1162 additions and 68 deletions

View File

@ -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"

View File

@ -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.

View File

@ -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())
}

View File

@ -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`
)
}
}

View File

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="h-12 w-12 animate-spin mx-auto text-gray-600" />
<p className="mt-4 text-gray-600">Completing sign-in...</p>
</div>
</div>
)
}

View File

@ -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<string | null>(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<HTMLFormElement>) => {
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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Sign In</CardTitle>
<CardDescription>
Access the United Tattoo Studio admin dashboard
{showAdminLogin
? "Admin emergency access"
: "Access the United Tattoo Studio dashboard"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{(error || urlError) && (
<Alert variant="destructive">
<AlertDescription>
{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.")}
</AlertDescription>
</Alert>
)}
{/* Credentials Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="nicholai@biohazardvfx.com"
required
{!showAdminLogin ? (
<>
{/* Nextcloud OAuth Primary Sign In */}
<Button
onClick={handleNextcloudSignIn}
className="w-full"
size="lg"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</form>
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
<>
<Cloud className="mr-2 h-5 w-5" />
Sign in with Nextcloud
</>
)}
</Button>
{/* Development Note */}
<div className="text-center text-sm text-gray-500">
<p>For development testing:</p>
<p className="text-xs mt-1">
Use any email/password combination.<br />
Admin: nicholai@biohazardvfx.com
</p>
</div>
{/* Info Text */}
<div className="text-center text-sm text-gray-600">
<p>Use your Nextcloud credentials to sign in.</p>
<p className="text-xs mt-2 text-gray-500">
You must be a member of the &apos;artists&apos; or
&apos;shop_admins&apos; group.
</p>
</div>
</>
) : (
<>
{/* Credentials Form (Admin Fallback) */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="nicholai@biohazardvfx.com"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</form>
{/* Development Note */}
<div className="text-center text-sm text-gray-500">
<p className="text-xs">
Admin emergency access only.<br />
For normal sign in, use Nextcloud OAuth.
</p>
</div>
</>
)}
</CardContent>
</Card>
</div>

View File

@ -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

View File

@ -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 }) {

147
lib/db.ts
View File

@ -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<User | null> {
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<User | null> {
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<User> {
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<User> {
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,

View File

@ -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<typeof envSchema>

180
lib/nextcloud-client.ts Normal file
View File

@ -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<T> {
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<NextcloudUserProfile | null> {
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<NextcloudUserProfile>
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<string[]> {
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<boolean> {
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'
}

View File

@ -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"