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_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"
|
||||
|
||||
41
CLAUDE.md
41
CLAUDE.md
@ -100,12 +100,34 @@ Main tables:
|
||||
|
||||
### Authentication (`lib/auth.ts`)
|
||||
|
||||
NextAuth.js setup with role-based access control:
|
||||
NextAuth.js setup with role-based access control and Nextcloud OAuth integration:
|
||||
|
||||
- **Primary Provider**: Nextcloud OAuth (recommended for all users)
|
||||
- Artists and admins sign in via their Nextcloud credentials
|
||||
- Auto-provisioning: Users in 'artists' or 'shop_admins' Nextcloud groups are automatically created
|
||||
- Group-based role assignment:
|
||||
- `admin` or `admins` group → SUPER_ADMIN
|
||||
- `shop_admins` group (configurable) → SHOP_ADMIN
|
||||
- `artists` group (configurable) → ARTIST (with auto-created artist profile)
|
||||
- Users not in authorized groups are denied access
|
||||
- Requires: `NEXTCLOUD_OAUTH_CLIENT_ID`, `NEXTCLOUD_OAUTH_CLIENT_SECRET`, `NEXTCLOUD_BASE_URL`
|
||||
|
||||
- **Fallback Provider**: Credentials (email/password)
|
||||
- Available only via `/auth/signin?admin=true` query parameter
|
||||
- Admin emergency access only
|
||||
- Dev mode: Any email/password combo creates a SUPER_ADMIN user for testing
|
||||
- Seed admin: `nicholai@biohazardvfx.com` is hardcoded as admin
|
||||
|
||||
- **Deprecated Providers**: Google/GitHub OAuth (still configured but not actively used)
|
||||
|
||||
- **Providers**: Credentials (email/password), optional Google/GitHub OAuth
|
||||
- **Dev mode**: Any email/password combo creates a SUPER_ADMIN user for testing
|
||||
- **Seed admin**: `nicholai@biohazardvfx.com` is hardcoded as admin
|
||||
- **Session strategy**: JWT (no database adapter currently)
|
||||
|
||||
- **Nextcloud Integration** (`lib/nextcloud-client.ts`):
|
||||
- `getNextcloudUserProfile(userId)` - Fetch user details from Nextcloud OCS API
|
||||
- `getNextcloudUserGroups(userId)` - Get user's group memberships
|
||||
- `determineUserRole(userId)` - Auto-assign role based on Nextcloud groups
|
||||
- Uses service account credentials (NEXTCLOUD_USERNAME/PASSWORD) for API access
|
||||
|
||||
- **Helper functions**:
|
||||
- `requireAuth(role?)` - Protect routes, throws if unauthorized
|
||||
- `getArtistSession()` - Get artist profile for logged-in artist users
|
||||
@ -140,7 +162,16 @@ Validates required environment variables at boot using Zod. Critical vars:
|
||||
- Database: `DATABASE_URL`, `DIRECT_URL` (Supabase URLs, though using D1)
|
||||
- Auth: `NEXTAUTH_URL`, `NEXTAUTH_SECRET`
|
||||
- Storage: AWS/R2 credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_BUCKET_NAME`, `AWS_ENDPOINT_URL`)
|
||||
- CalDAV: Nextcloud credentials (optional)
|
||||
- Nextcloud OAuth (required for artist authentication):
|
||||
- `NEXTCLOUD_BASE_URL` - Nextcloud instance URL (e.g., https://portal.united-tattoos.com)
|
||||
- `NEXTCLOUD_OAUTH_CLIENT_ID` - OAuth app client ID from Nextcloud
|
||||
- `NEXTCLOUD_OAUTH_CLIENT_SECRET` - OAuth app client secret from Nextcloud
|
||||
- `NEXTCLOUD_ARTISTS_GROUP` - Group name for artists (default: "artists")
|
||||
- `NEXTCLOUD_ADMINS_GROUP` - Group name for admins (default: "shop_admins")
|
||||
- Nextcloud CalDAV (optional, for calendar sync):
|
||||
- `NEXTCLOUD_USERNAME` - Service account username
|
||||
- `NEXTCLOUD_PASSWORD` - Service account password or app-specific password
|
||||
- `NEXTCLOUD_CALENDAR_BASE_PATH` - CalDAV path (default: "/remote.php/dav/calendars")
|
||||
|
||||
Note: The env validation expects Supabase URLs but actual runtime uses Cloudflare D1 via bindings.
|
||||
|
||||
|
||||
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 { 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 'artists' or
|
||||
'shop_admins' 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>
|
||||
|
||||
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 { 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
147
lib/db.ts
@ -1,14 +1,16 @@
|
||||
import type {
|
||||
Artist,
|
||||
PortfolioImage,
|
||||
Appointment,
|
||||
SiteSettings,
|
||||
CreateArtistInput,
|
||||
UpdateArtistInput,
|
||||
CreateAppointmentInput,
|
||||
import type {
|
||||
Artist,
|
||||
PortfolioImage,
|
||||
Appointment,
|
||||
SiteSettings,
|
||||
CreateArtistInput,
|
||||
UpdateArtistInput,
|
||||
CreateAppointmentInput,
|
||||
UpdateSiteSettingsInput,
|
||||
AppointmentFilters,
|
||||
FlashItem
|
||||
FlashItem,
|
||||
User,
|
||||
UserRole
|
||||
} from '@/types/database'
|
||||
|
||||
// Type for Cloudflare D1 database binding
|
||||
@ -37,6 +39,127 @@ export function getDB(env?: any): D1Database {
|
||||
return db as D1Database;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Management Functions
|
||||
*/
|
||||
|
||||
export async function getUserByEmail(email: string, env?: any): Promise<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,
|
||||
|
||||
@ -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
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]
|
||||
MIGRATE_TOKEN = "ut_migrate_20251006_rotated_1a2b3c"
|
||||
NEXTAUTH_URL = "https://united-tattoos.com"
|
||||
NODE_ENV = "production"
|
||||
|
||||
[assets]
|
||||
directory = ".open-next/assets"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user