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:
parent
3614271881
commit
0d38f81e2c
20
.env.example
20
.env.example
@ -17,6 +17,26 @@ GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
|||||||
GITHUB_CLIENT_ID="your-github-client-id"
|
GITHUB_CLIENT_ID="your-github-client-id"
|
||||||
GITHUB_CLIENT_SECRET="your-github-client-secret"
|
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
|
# File Storage Configuration
|
||||||
# AWS S3 or Cloudflare R2 for file uploads
|
# AWS S3 or Cloudflare R2 for file uploads
|
||||||
AWS_ACCESS_KEY_ID="your-aws-access-key-id"
|
AWS_ACCESS_KEY_ID="your-aws-access-key-id"
|
||||||
|
|||||||
41
CLAUDE.md
41
CLAUDE.md
@ -100,12 +100,34 @@ Main tables:
|
|||||||
|
|
||||||
### Authentication (`lib/auth.ts`)
|
### 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)
|
- **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**:
|
- **Helper functions**:
|
||||||
- `requireAuth(role?)` - Protect routes, throws if unauthorized
|
- `requireAuth(role?)` - Protect routes, throws if unauthorized
|
||||||
- `getArtistSession()` - Get artist profile for logged-in artist users
|
- `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)
|
- Database: `DATABASE_URL`, `DIRECT_URL` (Supabase URLs, though using D1)
|
||||||
- Auth: `NEXTAUTH_URL`, `NEXTAUTH_SECRET`
|
- Auth: `NEXTAUTH_URL`, `NEXTAUTH_SECRET`
|
||||||
- Storage: AWS/R2 credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_BUCKET_NAME`, `AWS_ENDPOINT_URL`)
|
- 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.
|
Note: The env validation expects Supabase URLs but actual runtime uses Cloudflare D1 via bindings.
|
||||||
|
|
||||||
|
|||||||
55
app/api/auth/nextcloud/authorize/route.ts
Normal file
55
app/api/auth/nextcloud/authorize/route.ts
Normal 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())
|
||||||
|
}
|
||||||
194
app/api/auth/nextcloud/callback/route.ts
Normal file
194
app/api/auth/nextcloud/callback/route.ts
Normal 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`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/auth/nextcloud/complete/page.tsx
Normal file
58
app/auth/nextcloud/complete/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -8,16 +8,17 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2, Cloud } from "lucide-react"
|
||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const urlError = searchParams.get("error")
|
const urlError = searchParams.get("error")
|
||||||
const callbackUrl = searchParams.get("callbackUrl") || "/admin"
|
const callbackUrl = searchParams.get("callbackUrl") || "/admin"
|
||||||
|
const showAdminLogin = searchParams.get("admin") === "true"
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<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">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle className="text-2xl font-bold">Sign In</CardTitle>
|
<CardTitle className="text-2xl font-bold">Sign In</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Access the United Tattoo Studio admin dashboard
|
{showAdminLogin
|
||||||
|
? "Admin emergency access"
|
||||||
|
: "Access the United Tattoo Studio dashboard"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{(error || urlError) && (
|
{(error || urlError) && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{error || (urlError === "CredentialsSignin"
|
{error ||
|
||||||
? "Invalid email or password. Please try again."
|
(urlError === "CredentialsSignin"
|
||||||
: "An error occurred during sign in. Please try again."
|
? "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>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Credentials Form */}
|
{!showAdminLogin ? (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<>
|
||||||
<div className="space-y-2">
|
{/* Nextcloud OAuth Primary Sign In */}
|
||||||
<Label htmlFor="email">Email</Label>
|
<Button
|
||||||
<Input
|
onClick={handleNextcloudSignIn}
|
||||||
id="email"
|
className="w-full"
|
||||||
name="email"
|
size="lg"
|
||||||
type="email"
|
|
||||||
placeholder="nicholai@biohazardvfx.com"
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
>
|
||||||
</div>
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
<Label htmlFor="password">Password</Label>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<Input
|
Signing in...
|
||||||
id="password"
|
</>
|
||||||
name="password"
|
) : (
|
||||||
type="password"
|
<>
|
||||||
placeholder="Enter your password"
|
<Cloud className="mr-2 h-5 w-5" />
|
||||||
required
|
Sign in with Nextcloud
|
||||||
disabled={isLoading}
|
</>
|
||||||
/>
|
)}
|
||||||
</div>
|
</Button>
|
||||||
<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 */}
|
{/* Info Text */}
|
||||||
<div className="text-center text-sm text-gray-500">
|
<div className="text-center text-sm text-gray-600">
|
||||||
<p>For development testing:</p>
|
<p>Use your Nextcloud credentials to sign in.</p>
|
||||||
<p className="text-xs mt-1">
|
<p className="text-xs mt-2 text-gray-500">
|
||||||
Use any email/password combination.<br />
|
You must be a member of the 'artists' or
|
||||||
Admin: nicholai@biohazardvfx.com
|
'shop_admins' group.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
317
docs/NEXTCLOUD-OAUTH-SETUP.md
Normal file
317
docs/NEXTCLOUD-OAUTH-SETUP.md
Normal 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
|
||||||
66
lib/auth.ts
66
lib/auth.ts
@ -4,21 +4,76 @@ import GitHubProvider from "next-auth/providers/github"
|
|||||||
import CredentialsProvider from "next-auth/providers/credentials"
|
import CredentialsProvider from "next-auth/providers/credentials"
|
||||||
import { env } from "./env"
|
import { env } from "./env"
|
||||||
import { UserRole } from "@/types/database"
|
import { UserRole } from "@/types/database"
|
||||||
|
import {
|
||||||
|
getNextcloudUserProfile,
|
||||||
|
getNextcloudUserGroups,
|
||||||
|
determineUserRole
|
||||||
|
} from "./nextcloud-client"
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
// Note: Database adapter will be configured via Supabase MCP
|
// Note: Database adapter will be configured via Supabase MCP
|
||||||
// For now, using JWT strategy without database adapter
|
// For now, using JWT strategy without database adapter
|
||||||
providers: [
|
providers: [
|
||||||
// Credentials provider for email/password login
|
// Credentials provider for email/password login (admin fallback) and Nextcloud OAuth completion
|
||||||
CredentialsProvider({
|
CredentialsProvider({
|
||||||
name: "credentials",
|
name: "credentials",
|
||||||
credentials: {
|
credentials: {
|
||||||
email: { label: "Email", type: "email" },
|
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)
|
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) {
|
if (!credentials?.email || !credentials?.password) {
|
||||||
console.log("Missing email or password")
|
console.log("Missing email or password")
|
||||||
return null
|
return null
|
||||||
@ -46,7 +101,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
name: credentials.email.split("@")[0],
|
name: credentials.email.split("@")[0],
|
||||||
role: UserRole.SUPER_ADMIN, // Give admin access for testing
|
role: UserRole.SUPER_ADMIN, // Give admin access for testing
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Created user:", user)
|
console.log("Created user:", user)
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
@ -92,6 +147,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
},
|
},
|
||||||
async signIn({ user, account, profile }) {
|
async signIn({ user, account, profile }) {
|
||||||
// Custom sign-in logic
|
// Custom sign-in logic
|
||||||
|
// Note: Nextcloud OAuth auto-provisioning happens in custom callback handler
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
async redirect({ url, baseUrl }) {
|
async redirect({ url, baseUrl }) {
|
||||||
|
|||||||
147
lib/db.ts
147
lib/db.ts
@ -1,14 +1,16 @@
|
|||||||
import type {
|
import type {
|
||||||
Artist,
|
Artist,
|
||||||
PortfolioImage,
|
PortfolioImage,
|
||||||
Appointment,
|
Appointment,
|
||||||
SiteSettings,
|
SiteSettings,
|
||||||
CreateArtistInput,
|
CreateArtistInput,
|
||||||
UpdateArtistInput,
|
UpdateArtistInput,
|
||||||
CreateAppointmentInput,
|
CreateAppointmentInput,
|
||||||
UpdateSiteSettingsInput,
|
UpdateSiteSettingsInput,
|
||||||
AppointmentFilters,
|
AppointmentFilters,
|
||||||
FlashItem
|
FlashItem,
|
||||||
|
User,
|
||||||
|
UserRole
|
||||||
} from '@/types/database'
|
} from '@/types/database'
|
||||||
|
|
||||||
// Type for Cloudflare D1 database binding
|
// Type for Cloudflare D1 database binding
|
||||||
@ -37,6 +39,127 @@ export function getDB(env?: any): D1Database {
|
|||||||
return db as 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
|
* Artist Management Functions
|
||||||
*/
|
*/
|
||||||
@ -705,6 +828,12 @@ export async function updateSiteSettings(data: UpdateSiteSettingsInput, env?: an
|
|||||||
|
|
||||||
// Type-safe query builder helpers
|
// Type-safe query builder helpers
|
||||||
export const db = {
|
export const db = {
|
||||||
|
users: {
|
||||||
|
findByEmail: getUserByEmail,
|
||||||
|
findById: getUserById,
|
||||||
|
create: createUser,
|
||||||
|
update: updateUser,
|
||||||
|
},
|
||||||
artists: {
|
artists: {
|
||||||
findMany: getArtists,
|
findMany: getArtists,
|
||||||
findUnique: getArtist,
|
findUnique: getArtist,
|
||||||
|
|||||||
@ -39,6 +39,12 @@ const envSchema = z.object({
|
|||||||
NEXTCLOUD_USERNAME: z.string().optional(),
|
NEXTCLOUD_USERNAME: z.string().optional(),
|
||||||
NEXTCLOUD_PASSWORD: z.string().optional(),
|
NEXTCLOUD_PASSWORD: z.string().optional(),
|
||||||
NEXTCLOUD_CALENDAR_BASE_PATH: z.string().default('/remote.php/dav/calendars'),
|
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>
|
export type Env = z.infer<typeof envSchema>
|
||||||
|
|||||||
180
lib/nextcloud-client.ts
Normal file
180
lib/nextcloud-client.ts
Normal 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'
|
||||||
|
}
|
||||||
@ -6,6 +6,8 @@ main = ".open-next/worker.js"
|
|||||||
|
|
||||||
[vars]
|
[vars]
|
||||||
MIGRATE_TOKEN = "ut_migrate_20251006_rotated_1a2b3c"
|
MIGRATE_TOKEN = "ut_migrate_20251006_rotated_1a2b3c"
|
||||||
|
NEXTAUTH_URL = "https://united-tattoos.com"
|
||||||
|
NODE_ENV = "production"
|
||||||
|
|
||||||
[assets]
|
[assets]
|
||||||
directory = ".open-next/assets"
|
directory = ".open-next/assets"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user