changes: - Removed clerk auth

- added defautl basic login
- types generated by ioenapi shcema
This commit is contained in:
2025-10-07 21:27:03 -06:00
parent bfc909634e
commit e78c2db32b
33 changed files with 3752 additions and 393 deletions

View File

@@ -8,6 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **TypeScript Type Generation from OpenAPI**
- Added `@hey-api/openapi-ts` for automatic type generation from backend Swagger spec
- Created `openapi-ts.config.ts` configuration using local swagger.json file (avoids SSL certificate issues)
- Added `pnpm generate:api` script that downloads swagger.json and generates types
- Added `pnpm generate:api:download` to download swagger.json from backend
- Added `pnpm generate:api:types` to generate types from local file
- Generated API client in `src/client/` with full type-safety
- Added `swagger.json` to `.gitignore` (temporary file)
- **JWT Authentication Helpers**
- Created `src/lib/auth.ts` with token management functions
- `getAuthToken()`, `setAuthTokens()`, `clearAuthToken()` helpers
- Multi-tenancy support with `getTenantId()` and `setTenantId()`
- User info storage helpers
- **Configured API Client**
- Created `src/lib/api/client.ts` with ASP.NET Boilerplate interceptors
- Automatic `Authorization: Bearer` header injection
- Automatic `Abp.TenantId` header for multi-tenancy
- Response unwrapping for ABP format (`{ result, success, error }`)
- Error handling and automatic redirect on unauthorized requests
- **Documentation**
- Created `src/lib/api/README.md` with comprehensive usage examples
- Created `TROUBLESHOOTING.md` with solutions to common issues
- Updated `CLAUDE.md` with TypeScript type generation workflow
- Added OpenAPI client documentation and benefits
- Documented SSL certificate workaround for localhost development
- **Project Setup**
- Added `src/client/` to `.gitignore` (generated files)
- Created `CLAUDE.md` with comprehensive project documentation for Claude Code
- Project overview and architecture description
- Common commands for both .NET backend and Next.js frontend
@@ -17,3 +44,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Version information
- Created `CHANGELOG.md` to track all project changes
- Added changelog management rules to `CLAUDE.md`
### Removed
- Removed Clerk authentication dependencies (`@clerk/nextjs`, `@clerk/themes`)
- Removed Clerk configuration from project (replaced with native JWT authentication)
- Removed all Clerk-related components and hooks from the codebase
- Removed Clerk references from `.gitignore`
### Fixed
- Fixed import path for generated API client (use `@/client/client.gen` instead of `@/client`)
- Resolved "Export client doesn't exist" error in auth-context.tsx
- Fixed SSL certificate issues when generating types from localhost by downloading swagger.json first
### Changed
- **Complete Authentication System Replacement**
- Replaced Clerk authentication with custom JWT-based authentication system
- Created `AuthProvider` and `useAuth` hook to manage authentication state
- Updated all components to use `useAuth` instead of Clerk's `useUser`
- Frontend now stores JWT tokens in localStorage instead of using Clerk
- Updated development workflow to include type generation step after backend changes
- **Custom Authentication Components**
- Created `CustomSignInForm` component replacing Clerk's SignIn
- Created `CustomSignUpForm` component replacing Clerk's SignUp
- Created `CustomUserProfile` component replacing Clerk's UserProfile
- Updated sign-in-view.tsx, sign-up-view.tsx, and profile-view-page.tsx to use custom components
- **Route Protection**
- Implemented Next.js middleware for route protection
- Middleware handles authentication redirects and route guards
- Simplified page.tsx files to rely on middleware for auth checks
- **Component Updates**
- Updated `providers.tsx` to use `AuthProvider` instead of `ClerkProvider`
- Updated `user-nav.tsx` to use `useAuth` hook with custom logout functionality
- Updated `app-sidebar.tsx` to use `useAuth` hook
- Maintained existing UI/UX while swapping authentication providers

111
CLAUDE.md
View File

@@ -50,7 +50,7 @@ The application implements dynamic OIDC (OpenID Connect) authentication with ext
**Architecture**: Feature-based organization using Next.js 15 App Router
- `src/app/`: Route structure with App Router conventions
- `(auth)/`: Authentication routes (sign-in, sign-up) using Clerk
- `(auth)/`: Authentication routes (sign-in, sign-up)
- `(dashboard)/`: Protected dashboard routes with parallel routes (`@area_stats`, `@bar_stats`, etc.)
- `dashboard/product/`, `dashboard/kanban/`, `dashboard/profile/`
@@ -67,7 +67,8 @@ The application implements dynamic OIDC (OpenID Connect) authentication with ext
**Tech Stack**:
- Next.js 15 with React 19
- Clerk for authentication
- JWT authentication with ASP.NET Boilerplate backend
- @hey-api/openapi-ts for type-safe API client generation
- Tailwind CSS v4
- shadcn-ui components
- Zod for schema validation
@@ -147,6 +148,12 @@ pnpm lint:fix
pnpm format
```
**Generate API types** (from OpenAPI spec):
```bash
pnpm generate:api
```
*Note: Backend must be running at https://localhost:44313*
**Pre-commit hooks**: Husky is configured with lint-staged for automatic formatting
## Key Concepts
@@ -179,10 +186,17 @@ The backend implements dynamic OIDC provider registration:
## Development Workflow
1. **Backend changes**: Update entities → add migration → run migrator → update app services/DTOs
1. **Backend changes**: Update entities → add migration → run migrator → update app services/DTOs → regenerate frontend types
2. **Frontend changes**: Follow feature-based organization, create components in appropriate feature folder
3. **API integration**: Frontend calls backend API (configure base URL in Next.js environment variables)
4. **Authentication flow**: Frontend uses Clerk → Backend validates JWT tokens
3. **API integration**:
- Run backend: `cd src/ASPBaseOIDC.Web.Host && dotnet run`
- Generate types: `cd src/ASPBaseOIDC.Web.Ui && pnpm generate:api`
- Use type-safe client: `import { client } from '@/lib/api/client'`
4. **Authentication flow**:
- Frontend authenticates via `/api/TokenAuth/Authenticate`
- JWT token stored in localStorage
- Client automatically adds `Authorization: Bearer <token>` header
- Backend validates JWT tokens
## Changelog Management
@@ -208,6 +222,89 @@ The backend implements dynamic OIDC provider registration:
This practice ensures all changes are documented and traceable for team members and future instances of Claude Code.
## TypeScript Type Generation (OpenAPI Client)
The frontend uses **@hey-api/openapi-ts** to automatically generate TypeScript types and a type-safe HTTP client from the backend's OpenAPI specification.
### Setup and Usage
**Prerequisites**: Backend must be running (`dotnet run` in `ASPBaseOIDC.Web.Host`)
**Generate types**:
```bash
cd src/ASPBaseOIDC.Web.Ui
pnpm generate:api
```
This generates:
- `src/client/types.gen.ts` - All TypeScript types matching backend DTOs
- `src/client/sdk.gen.ts` - Type-safe API client methods
- `src/client/client.gen.ts` - HTTP client configuration
**Configuration**: `openapi-ts.config.ts` in Web.Ui root
- Input: `https://localhost:44313/swagger/v1/swagger.json`
- Output: `src/client/`
- Plugins: TypeScript types + SDK generation
### Using the API Client
**Import the configured client**:
```typescript
import { client } from '@/lib/api/client';
```
**Server Component** (recommended):
```typescript
const response = await client.GET('/api/services/app/User/GetAll', {
params: { query: { maxResultCount: 10 } }
});
const users = response.data; // Fully typed!
```
**Client Component**:
```typescript
'use client';
const [data, setData] = useState();
useEffect(() => {
client.GET('/api/endpoint').then(r => setData(r.data));
}, []);
```
### Authentication Handling
The client in `src/lib/api/client.ts` automatically:
1. Adds `Authorization: Bearer <token>` from localStorage
2. Adds `Abp.TenantId` header if multi-tenancy is active
3. Unwraps ABP response format (`{ result, success, error }`)
4. Throws errors for failed requests
5. Redirects to login on unauthorized requests
**Authentication helpers** in `src/lib/auth.ts`:
- `getAuthToken()` - Get current JWT token
- `setAuthTokens()` - Store tokens after login
- `clearAuthToken()` - Clear tokens on logout
- `getTenantId()` / `setTenantId()` - Multi-tenancy support
### When to Regenerate Types
Run `pnpm generate:api` when:
- Backend DTOs change
- New endpoints are added
- Response/request schemas are modified
- After pulling backend changes from git
**Tip**: Add to your workflow after `dotnet build` succeeds
### Benefits
**Full type-safety** - TypeScript knows all endpoints, parameters, and responses
**IntelliSense** - Autocomplete for all API calls
**Compile-time errors** - Catch API mismatches before runtime
**No manual typing** - Types sync automatically with C# DTOs
**ABP integration** - Handles response unwrapping and error handling
**Example**: See `src/lib/api/README.md` for detailed usage examples
## Important Files
- `src/ASPBaseOIDC.Core/ASPBaseOIDCConsts.cs`: Global constants
@@ -215,6 +312,10 @@ This practice ensures all changes are documented and traceable for team members
- `src/ASPBaseOIDC.EntityFrameworkCore/EntityFrameworkCore/ASPBaseOIDCDbContext.cs`: Database context
- `src/ASPBaseOIDC.Web.Host/Startup/AuthConfigurer.cs`: Authentication configuration
- `src/ASPBaseOIDC.Web.Ui/package.json`: Frontend dependencies and scripts
- `src/ASPBaseOIDC.Web.Ui/openapi-ts.config.ts`: OpenAPI type generator configuration
- `src/ASPBaseOIDC.Web.Ui/src/lib/api/client.ts`: Configured API client with ABP interceptors
- `src/ASPBaseOIDC.Web.Ui/src/lib/auth.ts`: Authentication helpers and token management
- `src/ASPBaseOIDC.Web.Ui/src/lib/api/README.md`: API client usage guide with examples
- `src/ASPBaseOIDC.Web.Ui/src/app/layout.tsx`: Root layout with providers
## Version Information

View File

@@ -35,9 +35,11 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
# generated API client
/src/client
swagger.json
.idea/
# clerk configuration (can include secrets)
/.clerk/
# cursor

View File

@@ -0,0 +1,90 @@
# Quick Start Guide
## Prerequisites
1. **Backend running** at `https://localhost:44313`
2. **Node.js** and **pnpm** installed
## Setup Steps
### 1. Install Dependencies
```bash
pnpm install
```
### 2. Generate API Types
```bash
# Downloads swagger.json and generates TypeScript types
pnpm generate:api
```
This creates the `src/client/` directory with:
- `types.gen.ts` - All TypeScript types from backend DTOs
- `sdk.gen.ts` - Type-safe API methods
- `client.gen.ts` - Configured HTTP client
### 3. Create Environment File
```bash
cp env.example.txt .env.local
```
Add your backend API URL:
```env
NEXT_PUBLIC_API_URL=https://localhost:44313
```
### 4. Start Development Server
```bash
pnpm dev
```
The app will be running at `http://localhost:3000`
---
## First-Time Use
1. Navigate to `http://localhost:3000`
2. You'll be redirected to `/auth/sign-in`
3. Login with your backend credentials
4. You'll be redirected to `/dashboard/overview`
---
## Common Commands
| Command | Description |
|---------|-------------|
| `pnpm dev` | Start development server with Turbopack |
| `pnpm build` | Build for production |
| `pnpm start` | Start production server |
| `pnpm lint` | Run ESLint |
| `pnpm format` | Format code with Prettier |
| `pnpm generate:api` | Regenerate API types from backend |
---
## Project Structure
```
src/
├── app/ # Next.js App Router pages
│ ├── (auth)/ # Authentication routes
│ └── dashboard/ # Protected dashboard routes
├── client/ # 🔥 Generated API client (don't edit!)
├── components/ # Shared UI components
│ ├── ui/ # shadcn-ui components
│ └── layout/ # Layout components
├── contexts/ # React contexts
│ └── auth-context.tsx # Authentication state management
├── features/ # Feature-specific components
│ ├── auth/ # Login/Register forms
│ └── profile/ # User profile
├── lib/ # Utilities and configurations
│ ├── api/ # API client configuration
│ └── auth.ts # Authentication helpers
└── middleware.ts # Route protection

View File

@@ -0,0 +1,144 @@
# Troubleshooting Guide
## API Type Generation Issues
### Problem: "Request failed with status 200"
**Error:**
```
Error: Request failed with status 200
```
**Cause:** This error occurs when trying to generate types directly from `https://localhost:44313` due to self-signed SSL certificates.
**Solution:** The project is configured to download the swagger.json file locally first, then generate types from the local file.
**How it works:**
1. `pnpm generate:api:download` - Downloads swagger.json using curl (which accepts self-signed certs with `-k` flag)
2. `pnpm generate:api:types` - Generates types from the local swagger.json file
3. `pnpm generate:api` - Runs both commands sequentially
**Configuration:** See `openapi-ts.config.ts`:
```typescript
{
input: './swagger.json', // Local file instead of HTTPS URL
// ...
}
```
---
## Authentication Issues
### Problem: "Unauthorized" after login
**Possible causes:**
1. Token not being saved to localStorage
2. Token not being sent in Authorization header
3. Backend rejecting the token
**Debug steps:**
1. Check browser DevTools > Application > Local Storage
- Should see `accessToken` and `encryptedAccessToken`
2. Check Network tab for API requests
- Look for `Authorization: Bearer <token>` header
3. Check backend logs for token validation errors
**Solution:** Verify `src/lib/api/client.ts` has the request interceptor configured.
---
## CORS Issues
### Problem: "CORS policy: No 'Access-Control-Allow-Origin' header"
**Cause:** Backend not configured to allow requests from the Next.js dev server.
**Solution:** Add `http://localhost:3000` to CORS origins in backend `appsettings.json`:
```json
{
"App": {
"CorsOrigins": "http://localhost:3000,http://localhost:4200"
}
}
```
---
## Build Issues
### Problem: "Export client doesn't exist"
**Error:**
```
Export client doesn't exist in target module
The export client was not found in module [project]/src/client/index.ts
```
**Cause:** Incorrect import path for the generated API client.
**Solution:** Always import the client from `@/client/client.gen`:
```typescript
// ✅ Correct
import { client } from '@/client/client.gen';
// ❌ Wrong
import { client } from '@/client';
```
**Why:** The `src/client/index.ts` file only re-exports types and SDK methods, not the client instance. The actual client is exported from `client.gen.ts`.
---
### Problem: Types not found after generation
**Error:**
```
Cannot find module '@/client' or its corresponding type declarations
```
**Solution:**
1. Ensure `pnpm generate:api` completed successfully
2. Check that `src/client/` directory exists and contains:
- `index.ts`
- `types.gen.ts`
- `sdk.gen.ts`
- `client.gen.ts`
3. Restart your IDE/editor's TypeScript server
4. Clear Next.js cache: `rm -rf .next` and restart dev server
---
## Runtime Issues
### Problem: "Module not found: Can't resolve '@/client'"
**Cause:** Types haven't been generated yet.
**Solution:**
```bash
# Make sure backend is running
cd ../ASPBaseOIDC.Web.Host
dotnet run
# In another terminal, generate types
cd src/ASPBaseOIDC.Web.Ui
pnpm generate:api
```
---
## Development Workflow
**Recommended order:**
1. Start backend: `dotnet run` in `ASPBaseOIDC.Web.Host`
2. Generate types: `pnpm generate:api` in `ASPBaseOIDC.Web.Ui`
3. Start frontend: `pnpm dev`
**When to regenerate types:**
- After changing DTOs in backend
- After adding new API endpoints
- After pulling backend changes from git
- When seeing TypeScript errors about missing types

View File

@@ -0,0 +1,25 @@
import { defaultPlugins, defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
// Use local file instead of HTTPS URL to avoid SSL certificate issues
input: './swagger.json',
output: {
path: 'src/client',
format: false,
lint: false,
},
plugins: [
...defaultPlugins,
{
name: '@hey-api/typescript',
enums: 'javascript',
},
{
name: '@hey-api/sdk',
asClass: false,
},
],
client: {
name: '@hey-api/client-fetch',
},
});

View File

@@ -15,7 +15,10 @@
"lint:strict": "eslint --max-warnings=0 src",
"format": "prettier --write .",
"format:check": "prettier -c -w .",
"prepare": "husky"
"prepare": "husky",
"generate:api:download": "curl -k https://localhost:44313/swagger/v1/swagger.json -o swagger.json",
"generate:api:types": "openapi-ts",
"generate:api": "pnpm generate:api:download && pnpm generate:api:types"
},
"lint-staged": {
"**/*.{js,jsx,tsx,ts,css,less,scss,sass}": [
@@ -23,8 +26,6 @@
]
},
"dependencies": {
"@clerk/nextjs": "^6.12.12",
"@clerk/themes": "^2.2.26",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
@@ -78,6 +79,7 @@
"nextjs-toploader": "^3.7.15",
"nuqs": "^2.4.1",
"postcss": "8.4.49",
"radix-ui": "^1.4.3",
"react": "19.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "19.0.0",
@@ -100,6 +102,7 @@
},
"devDependencies": {
"@faker-js/faker": "^9.3.0",
"@hey-api/openapi-ts": "^0.85.0",
"@types/node": "22.10.2",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.2",

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,6 @@
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
export default async function Dashboard() {
const { userId } = await auth();
if (!userId) {
return redirect('/auth/sign-in');
} else {
redirect('/dashboard/overview');
}
export default function Dashboard() {
// Middleware will handle authentication redirect
redirect('/dashboard/overview');
}

View File

@@ -0,0 +1,162 @@
"use client";
import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { z } from "zod";
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(8, "Password must be at least 8 characters long"),
});
const Login04Page = () => {
const form = useForm<z.infer<typeof formSchema>>({
defaultValues: {
email: "",
password: "",
},
resolver: zodResolver(formSchema),
});
const onSubmit = (data: z.infer<typeof formSchema>) => {
console.log(data);
};
return (
<div className="h-screen flex items-center justify-center">
<div className="w-full h-full grid lg:grid-cols-2 p-4">
<div className="max-w-xs m-auto w-full flex flex-col items-center">
<Logo className="h-9 w-9" />
<p className="mt-4 text-xl font-semibold tracking-tight">
Log in to Shadcn UI Blocks
</p>
<Button className="mt-8 w-full gap-3">
<GoogleLogo />
Continue with Google
</Button>
<div className="my-7 w-full flex items-center justify-center overflow-hidden">
<Separator />
<span className="text-sm px-2">OR</span>
<Separator />
</div>
<Form {...form}>
<form
className="w-full space-y-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Email"
className="w-full"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
className="w-full"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="mt-4 w-full">
Continue with Email
</Button>
</form>
</Form>
<div className="mt-5 space-y-5">
<Link
href="#"
className="text-sm block underline text-muted-foreground text-center"
>
Forgot your password?
</Link>
<p className="text-sm text-center">
Don&apos;t have an account?
<Link href="#" className="ml-1 underline text-muted-foreground">
Create account
</Link>
</p>
</div>
</div>
<div className="bg-muted hidden lg:block rounded-lg border" />
</div>
</div>
);
};
const GoogleLogo = () => (
<svg
width="1.2em"
height="1.2em"
id="icon-google"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="inline-block shrink-0 align-sub text-inherit size-lg"
>
<g clipPath="url(#clip0)">
<path
d="M15.6823 8.18368C15.6823 7.63986 15.6382 7.0931 15.5442 6.55811H7.99829V9.63876H12.3194C12.1401 10.6323 11.564 11.5113 10.7203 12.0698V14.0687H13.2983C14.8122 12.6753 15.6823 10.6176 15.6823 8.18368Z"
fill="#4285F4"
></path>
<path
d="M7.99812 16C10.1558 16 11.9753 15.2915 13.3011 14.0687L10.7231 12.0698C10.0058 12.5578 9.07988 12.8341 8.00106 12.8341C5.91398 12.8341 4.14436 11.426 3.50942 9.53296H0.849121V11.5936C2.2072 14.295 4.97332 16 7.99812 16Z"
fill="#34A853"
></path>
<path
d="M3.50665 9.53295C3.17154 8.53938 3.17154 7.4635 3.50665 6.46993V4.4093H0.849292C-0.285376 6.66982 -0.285376 9.33306 0.849292 11.5936L3.50665 9.53295Z"
fill="#FBBC04"
></path>
<path
d="M7.99812 3.16589C9.13867 3.14825 10.241 3.57743 11.067 4.36523L13.3511 2.0812C11.9048 0.723121 9.98526 -0.0235266 7.99812 -1.02057e-05C4.97332 -1.02057e-05 2.2072 1.70493 0.849121 4.40932L3.50648 6.46995C4.13848 4.57394 5.91104 3.16589 7.99812 3.16589Z"
fill="#EA4335"
></path>
</g>
<defs>
<clipPath id="clip0">
<rect width="15.6825" height="16" fill="white"></rect>
</clipPath>
</defs>
</svg>
);
export default Login04Page;

View File

@@ -1,12 +1,6 @@
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
export default async function Page() {
const { userId } = await auth();
if (!userId) {
return redirect('/auth/sign-in');
} else {
redirect('/dashboard/overview');
}
export default function Page() {
// Middleware will handle authentication redirect
redirect('/dashboard/overview');
}

View File

@@ -0,0 +1,11 @@
export const config = {
appUrl:
process.env.NODE_ENV === "production"
? process.env.VERCEL_PROJECT_PRODUCTION_URL ??
process.env.NEXT_PUBLIC_APP_URL!
: "localhost:3000",
social: {
github: "https://github.com/akash3444/shadcn-ui-blocks",
twitter: "https://twitter.com/shadcnui_blocks",
},
};

View File

@@ -31,7 +31,7 @@ import {
import { UserAvatarProfile } from '@/components/user-avatar-profile';
import { navItems } from '@/constants/data';
import { useMediaQuery } from '@/hooks/use-media-query';
import { useUser } from '@clerk/nextjs';
import { useAuth } from '@/contexts/auth-context';
import {
IconBell,
IconChevronRight,
@@ -41,7 +41,6 @@ import {
IconPhotoUp,
IconUserCircle
} from '@tabler/icons-react';
import { SignOutButton } from '@clerk/nextjs';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import * as React from 'react';
@@ -62,7 +61,7 @@ const tenants = [
export default function AppSidebar() {
const pathname = usePathname();
const { isOpen } = useMediaQuery();
const { user } = useUser();
const { user, logout } = useAuth();
const router = useRouter();
const handleSwitchTenant = (_tenantId: string) => {
// Tenant switching functionality would be implemented here
@@ -198,9 +197,9 @@ export default function AppSidebar() {
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownMenuItem onClick={logout}>
<IconLogout className='mr-2 h-4 w-4' />
<SignOutButton redirectUrl='/auth/sign-in' />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,8 +1,6 @@
'use client';
import { ClerkProvider } from '@clerk/nextjs';
import { dark } from '@clerk/themes';
import { useTheme } from 'next-themes';
import React from 'react';
import { AuthProvider } from '@/contexts/auth-context';
import { ActiveThemeProvider } from '../active-theme';
export default function Providers({
@@ -12,19 +10,10 @@ export default function Providers({
activeThemeValue: string;
children: React.ReactNode;
}) {
// we need the resolvedTheme value to set the baseTheme for clerk based on the dark or light theme
const { resolvedTheme } = useTheme();
return (
<>
<ActiveThemeProvider initialTheme={activeThemeValue}>
<ClerkProvider
appearance={{
baseTheme: resolvedTheme === 'dark' ? dark : undefined
}}
>
{children}
</ClerkProvider>
<AuthProvider>{children}</AuthProvider>
</ActiveThemeProvider>
</>
);

View File

@@ -10,11 +10,13 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { UserAvatarProfile } from '@/components/user-avatar-profile';
import { SignOutButton, useUser } from '@clerk/nextjs';
import { useAuth } from '@/contexts/auth-context';
import { useRouter } from 'next/navigation';
export function UserNav() {
const { user } = useUser();
const { user, logout } = useAuth();
const router = useRouter();
if (user) {
return (
<DropdownMenu>
@@ -35,7 +37,7 @@ export function UserNav() {
{user.fullName}
</p>
<p className='text-muted-foreground text-xs leading-none'>
{user.emailAddresses[0].emailAddress}
{user.emailAddresses[0]?.emailAddress}
</p>
</div>
</DropdownMenuLabel>
@@ -49,8 +51,8 @@ export function UserNav() {
<DropdownMenuItem>New Team</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<SignOutButton redirectUrl='/auth/sign-in' />
<DropdownMenuItem onClick={logout}>
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -0,0 +1,14 @@
"use client";
import { cn } from "@/lib/utils";
export const Logo = ({ className, ...props }: React.ComponentProps<"img">) => {
return (
<img
src="/images/android-chrome-192x192.png"
alt="logo"
className={cn("h-7 w-7", className)}
{...props}
/>
);
};

View File

@@ -1,8 +1,8 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from "react"
import { Slot as SlotPrimitive } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@@ -10,30 +10,30 @@ const buttonVariants = cva(
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9'
}
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: 'default',
size: 'default'
}
variant: "default",
size: "default",
},
}
);
)
function Button({
className,
@@ -41,19 +41,19 @@ function Button({
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button';
const Comp = asChild ? SlotPrimitive.Slot : "button"
return (
<Comp
data-slot='button'
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
)
}
export { Button, buttonVariants };
export { Button, buttonVariants }

View File

@@ -1,41 +1,22 @@
'use client';
"use client";
import * as React from "react";
import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui";
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
FormProvider,
useFormContext,
UseFormReturn,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues
} from 'react-hook-form';
type FieldValues,
} from "react-hook-form";
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = ({
children,
onSubmit,
form,
className
}: {
children: React.ReactNode;
onSubmit: (data: any) => void;
form: UseFormReturn<any, any, undefined>;
className?: string;
}) => {
return (
<FormProvider {...form}>
<form onSubmit={onSubmit} className={className}>
{children}
</form>
</FormProvider>
);
};
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
@@ -69,7 +50,7 @@ const useFormField = () => {
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
@@ -80,7 +61,7 @@ const useFormField = () => {
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState
...fieldState,
};
};
@@ -92,14 +73,14 @@ const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot='form-item'
className={cn('grid gap-2', className)}
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
@@ -114,22 +95,22 @@ function FormLabel({
return (
<Label
data-slot='form-label'
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
function FormControl({ ...props }: React.ComponentProps<typeof SlotPrimitive.Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot='form-control'
<SlotPrimitive.Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
@@ -142,22 +123,22 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
);
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot='form-description'
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : props.children;
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
@@ -165,9 +146,9 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot='form-message'
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm', className)}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
@@ -183,5 +164,5 @@ export {
FormControl,
FormDescription,
FormMessage,
FormField
FormField,
};

View File

@@ -1,21 +1,21 @@
import * as React from 'react';
import * as React from "react"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot='input'
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
);
)
}
export { Input };
export { Input }

View File

@@ -1,9 +1,9 @@
'use client';
"use client"
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
function Label({
className,
@@ -11,14 +11,14 @@ function Label({
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot='label'
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
);
)
}
export { Label };
export { Label }

View File

@@ -1,28 +1,28 @@
'use client';
"use client"
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = 'horizontal',
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot='separator-root'
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
);
)
}
export { Separator };
export { Separator }

View File

@@ -0,0 +1,162 @@
'use client';
import { client } from '@/lib/api/client';
import {
clearAuthToken,
getAuthToken,
getUserInfo,
isAuthenticated,
setAuthTokens,
setUserInfo,
type AuthTokens,
type UserInfo
} from '@/lib/auth';
import { useRouter } from 'next/navigation';
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode
} from 'react';
interface User {
id: number;
userName: string;
name?: string;
surname?: string;
emailAddress?: string;
fullName?: string;
imageUrl?: string;
emailAddresses: Array<{ emailAddress: string }>;
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (
userNameOrEmailAddress: string,
password: string
) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
// Load user from localStorage on mount
useEffect(() => {
const loadUser = () => {
if (isAuthenticated()) {
const userInfo = getUserInfo();
if (userInfo) {
setUser(mapUserInfoToUser(userInfo));
}
}
setIsLoading(false);
};
loadUser();
}, []);
const mapUserInfoToUser = (userInfo: UserInfo): User => {
const fullName = [userInfo.name, userInfo.surname]
.filter(Boolean)
.join(' ');
return {
id: userInfo.id,
userName: userInfo.userName,
name: userInfo.name,
surname: userInfo.surname,
emailAddress: userInfo.emailAddress,
fullName: fullName || userInfo.userName,
imageUrl: undefined, // Could be fetched from backend if available
emailAddresses: userInfo.emailAddress
? [{ emailAddress: userInfo.emailAddress }]
: []
};
};
const login = useCallback(
async (userNameOrEmailAddress: string, password: string) => {
try {
const response = await client.POST('/api/TokenAuth/Authenticate', {
body: {
userNameOrEmailAddress,
password,
rememberClient: false
}
});
if (response.data) {
// Save tokens
setAuthTokens({
accessToken: response.data.accessToken || '',
encryptedAccessToken: response.data.encryptedAccessToken || '',
expireInSeconds: response.data.expireInSeconds || 0
});
// Save user info
const userInfo: UserInfo = {
id: response.data.userId || 0,
userName: userNameOrEmailAddress,
emailAddress: undefined, // Will need to fetch from user profile endpoint
name: undefined,
surname: undefined
};
setUserInfo(userInfo);
setUser(mapUserInfoToUser(userInfo));
// Redirect to dashboard
router.push('/dashboard/overview');
}
} catch (error) {
console.error('Login failed:', error);
throw error;
}
},
[router]
);
const logout = useCallback(() => {
clearAuthToken();
setUser(null);
router.push('/auth/sign-in');
}, [router]);
const refreshUser = useCallback(async () => {
// Optionally fetch updated user info from backend
const userInfo = getUserInfo();
if (userInfo) {
setUser(mapUserInfoToUser(userInfo));
}
}, []);
const value: AuthContextType = {
user,
isLoading,
isAuthenticated: !!user,
login,
logout,
refreshUser
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,108 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/contexts/auth-context';
import { useState } from 'react';
import { toast } from 'sonner';
export function CustomSignInForm() {
const { login } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
userNameOrEmailAddress: '',
password: ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await login(formData.userNameOrEmailAddress, formData.password);
toast.success('Successfully signed in!');
} catch (error: any) {
console.error('Login error:', error);
toast.error(error.message || 'Failed to sign in. Please check your credentials.');
} finally {
setIsLoading(false);
}
};
return (
<Card className='w-full max-w-md'>
<CardHeader className='space-y-1'>
<CardTitle className='text-2xl font-bold'>Sign In</CardTitle>
<CardDescription>
Enter your credentials to access your account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='userNameOrEmailAddress'>Username or Email</Label>
<Input
id='userNameOrEmailAddress'
type='text'
placeholder='username or email@example.com'
value={formData.userNameOrEmailAddress}
onChange={(e) =>
setFormData({
...formData,
userNameOrEmailAddress: e.target.value
})
}
required
disabled={isLoading}
/>
</div>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='password'>Password</Label>
<a
href='/auth/forgot-password'
className='text-sm text-muted-foreground hover:text-primary'
>
Forgot password?
</a>
</div>
<Input
id='password'
type='password'
placeholder='••••••••'
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required
disabled={isLoading}
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
<div className='mt-4 text-center text-sm'>
Don&apos;t have an account?{' '}
<a
href='/auth/sign-up'
className='text-primary hover:underline font-medium'
>
Sign up
</a>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,191 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { client } from '@/lib/api/client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
export function CustomSignUpForm() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
userName: '',
name: '',
surname: '',
emailAddress: '',
password: '',
confirmPassword: ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validation
if (formData.password !== formData.confirmPassword) {
toast.error('Passwords do not match');
return;
}
if (formData.password.length < 8) {
toast.error('Password must be at least 8 characters long');
return;
}
setIsLoading(true);
try {
await client.POST('/api/services/app/Account/Register', {
body: {
userName: formData.userName,
name: formData.name,
surname: formData.surname,
emailAddress: formData.emailAddress,
password: formData.password
}
});
toast.success('Account created successfully! Please sign in.');
router.push('/auth/sign-in');
} catch (error: any) {
console.error('Registration error:', error);
toast.error(
error.message || 'Failed to create account. Please try again.'
);
} finally {
setIsLoading(false);
}
};
return (
<Card className='w-full max-w-md'>
<CardHeader className='space-y-1'>
<CardTitle className='text-2xl font-bold'>Create an account</CardTitle>
<CardDescription>
Enter your information to create your account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-2'>
<Label htmlFor='name'>First Name</Label>
<Input
id='name'
type='text'
placeholder='John'
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
required
disabled={isLoading}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='surname'>Last Name</Label>
<Input
id='surname'
type='text'
placeholder='Doe'
value={formData.surname}
onChange={(e) =>
setFormData({ ...formData, surname: e.target.value })
}
required
disabled={isLoading}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='userName'>Username</Label>
<Input
id='userName'
type='text'
placeholder='johndoe'
value={formData.userName}
onChange={(e) =>
setFormData({ ...formData, userName: e.target.value })
}
required
disabled={isLoading}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='emailAddress'>Email</Label>
<Input
id='emailAddress'
type='email'
placeholder='john@example.com'
value={formData.emailAddress}
onChange={(e) =>
setFormData({ ...formData, emailAddress: e.target.value })
}
required
disabled={isLoading}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='password'>Password</Label>
<Input
id='password'
type='password'
placeholder='••••••••'
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required
disabled={isLoading}
/>
<p className='text-xs text-muted-foreground'>
Must be at least 8 characters
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='confirmPassword'>Confirm Password</Label>
<Input
id='confirmPassword'
type='password'
placeholder='••••••••'
value={formData.confirmPassword}
onChange={(e) =>
setFormData({ ...formData, confirmPassword: e.target.value })
}
required
disabled={isLoading}
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create Account'}
</Button>
</form>
<div className='mt-4 text-center text-sm'>
Already have an account?{' '}
<a
href='/auth/sign-in'
className='text-primary hover:underline font-medium'
>
Sign in
</a>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,27 +1,27 @@
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { SignIn as ClerkSignInForm } from '@clerk/nextjs';
import { CustomSignInForm } from './custom-sign-in-form';
import { GitHubLogoIcon } from '@radix-ui/react-icons';
import { IconStar } from '@tabler/icons-react';
import { Metadata } from 'next';
import Link from 'next/link';
export const metadata: Metadata = {
title: 'Authentication',
description: 'Authentication forms built using the components.'
title: 'Sign In',
description: 'Sign in to your account'
};
export default function SignInViewPage({ stars }: { stars: number }) {
return (
<div className='relative h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0'>
<Link
href='/examples/authentication'
href='/auth/sign-up'
className={cn(
buttonVariants({ variant: 'ghost' }),
'absolute top-4 right-4 hidden md:top-8 md:right-8'
'absolute top-4 right-4 md:top-8 md:right-8'
)}
>
Login
Sign Up
</Link>
<div className='bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-r'>
<div className='absolute inset-0 bg-zinc-900' />
@@ -71,11 +71,8 @@ export default function SignInViewPage({ stars }: { stars: number }) {
<span className='font-display font-medium'>{stars}</span>
</div>
</Link>
<ClerkSignInForm
initialValues={{
emailAddress: 'your_mail+clerk_test@example.com'
}}
/>
<CustomSignInForm />
<p className='text-muted-foreground px-8 text-center text-sm'>
By clicking continue, you agree to our{' '}

View File

@@ -1,27 +1,27 @@
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { SignUp as ClerkSignUpForm } from '@clerk/nextjs';
import { CustomSignUpForm } from './custom-sign-up-form';
import { GitHubLogoIcon } from '@radix-ui/react-icons';
import { IconStar } from '@tabler/icons-react';
import { Metadata } from 'next';
import Link from 'next/link';
export const metadata: Metadata = {
title: 'Authentication',
description: 'Authentication forms built using the components.'
title: 'Sign Up',
description: 'Create a new account'
};
export default function SignUpViewPage({ stars }: { stars: number }) {
return (
<div className='relative h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0'>
<Link
href='/examples/authentication'
href='/auth/sign-in'
className={cn(
buttonVariants({ variant: 'ghost' }),
'absolute top-4 right-4 hidden md:top-8 md:right-8'
'absolute top-4 right-4 md:top-8 md:right-8'
)}
>
Sign Up
Sign In
</Link>
<div className='bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-r'>
<div className='absolute inset-0 bg-zinc-900' />
@@ -71,11 +71,9 @@ export default function SignUpViewPage({ stars }: { stars: number }) {
<span className='font-display font-medium'>{stars}</span>
</div>
</Link>
<ClerkSignUpForm
initialValues={{
emailAddress: 'your_mail+clerk_test@example.com'
}}
/>
<CustomSignUpForm />
<p className='text-muted-foreground px-8 text-center text-sm'>
By clicking continue, you agree to our{' '}
<Link

View File

@@ -0,0 +1,210 @@
'use client';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useAuth } from '@/contexts/auth-context';
import { useState } from 'react';
import { toast } from 'sonner';
export function CustomUserProfile() {
const { user, refreshUser } = useAuth();
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState({
name: user?.name || '',
surname: user?.surname || '',
emailAddress: user?.emailAddress || ''
});
if (!user) {
return null;
}
const handleSave = async () => {
setIsSaving(true);
try {
// TODO: Call backend API to update user profile
// await client.PUT('/api/services/app/User/Update', { body: formData });
toast.success('Profile updated successfully');
await refreshUser();
setIsEditing(false);
} catch (error: any) {
console.error('Update profile error:', error);
toast.error(error.message || 'Failed to update profile');
} finally {
setIsSaving(false);
}
};
const handleCancel = () => {
setFormData({
name: user?.name || '',
surname: user?.surname || '',
emailAddress: user?.emailAddress || ''
});
setIsEditing(false);
};
return (
<div className='space-y-6'>
<div className='flex items-center space-x-4'>
<Avatar className='h-20 w-20'>
<AvatarFallback className='text-2xl'>
{user.fullName?.slice(0, 2)?.toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<div>
<h2 className='text-2xl font-bold'>{user.fullName}</h2>
<p className='text-muted-foreground'>{user.emailAddress}</p>
</div>
</div>
<Separator />
<Tabs defaultValue='profile' className='w-full'>
<TabsList className='grid w-full grid-cols-3'>
<TabsTrigger value='profile'>Profile</TabsTrigger>
<TabsTrigger value='security'>Security</TabsTrigger>
<TabsTrigger value='preferences'>Preferences</TabsTrigger>
</TabsList>
<TabsContent value='profile' className='space-y-4'>
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>
Update your account profile information
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-2'>
<Label htmlFor='name'>First Name</Label>
<Input
id='name'
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
disabled={!isEditing || isSaving}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='surname'>Last Name</Label>
<Input
id='surname'
value={formData.surname}
onChange={(e) =>
setFormData({ ...formData, surname: e.target.value })
}
disabled={!isEditing || isSaving}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='username'>Username</Label>
<Input id='username' value={user.userName} disabled />
<p className='text-xs text-muted-foreground'>
Username cannot be changed
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='email'>Email Address</Label>
<Input
id='email'
type='email'
value={formData.emailAddress}
onChange={(e) =>
setFormData({ ...formData, emailAddress: e.target.value })
}
disabled={!isEditing || isSaving}
/>
</div>
<div className='flex justify-end space-x-2'>
{!isEditing ? (
<Button onClick={() => setIsEditing(true)}>
Edit Profile
</Button>
) : (
<>
<Button
variant='outline'
onClick={handleCancel}
disabled={isSaving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value='security' className='space-y-4'>
<Card>
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>
Update your password to keep your account secure
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='current-password'>Current Password</Label>
<Input id='current-password' type='password' />
</div>
<div className='space-y-2'>
<Label htmlFor='new-password'>New Password</Label>
<Input id='new-password' type='password' />
</div>
<div className='space-y-2'>
<Label htmlFor='confirm-password'>Confirm New Password</Label>
<Input id='confirm-password' type='password' />
</div>
<div className='flex justify-end'>
<Button>Update Password</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value='preferences' className='space-y-4'>
<Card>
<CardHeader>
<CardTitle>Preferences</CardTitle>
<CardDescription>Manage your account preferences</CardDescription>
</CardHeader>
<CardContent>
<p className='text-muted-foreground'>
Preferences settings coming soon...
</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,9 +1,9 @@
import { UserProfile } from '@clerk/nextjs';
import { CustomUserProfile } from './custom-user-profile';
export default function ProfileViewPage() {
return (
<div className='flex w-full flex-col p-4'>
<UserProfile />
<CustomUserProfile />
</div>
);
}

View File

@@ -0,0 +1,224 @@
# API Client Usage Guide
This directory contains the configured API client for communicating with the ASP.NET Boilerplate backend.
## Setup
### 1. Start the backend
```bash
cd ../../ASPBaseOIDC.Web.Host
dotnet run
```
The backend should be running at `https://localhost:44313`
### 2. Generate types from OpenAPI
```bash
pnpm generate:api
```
This will:
1. Download the OpenAPI spec from `https://localhost:44313/swagger/v1/swagger.json` to a local file
2. Generate TypeScript types in `src/client/` from the local file
3. Create a type-safe client with all your backend endpoints
**Note:** We use a local file instead of fetching directly from the HTTPS URL to avoid SSL certificate issues with localhost.
**Alternative commands:**
- `pnpm generate:api:download` - Only download the swagger.json file
- `pnpm generate:api:types` - Only generate types from existing swagger.json
### 3. Use the client in your components
## Usage Examples
### Server Component (recommended)
```typescript
// app/dashboard/users/page.tsx
import { client } from '@/lib/api/client';
export default async function UsersPage() {
// Fully type-safe API call
const response = await client.GET('/api/services/app/User/GetAll', {
params: {
query: {
maxResultCount: 10,
skipCount: 0
}
}
});
const users = response.data; // TypeScript knows the exact type!
return (
<div>
<h1>Users</h1>
<ul>
{users?.items?.map(user => (
<li key={user.id}>{user.userName}</li>
))}
</ul>
</div>
);
}
```
### Client Component
```typescript
'use client';
import { client } from '@/lib/api/client';
import { useEffect, useState } from 'react';
export function UsersList() {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchUsers() {
try {
const response = await client.GET('/api/services/app/User/GetAll');
setUsers(response.data?.items || []);
} catch (error) {
console.error('Failed to fetch users:', error);
} finally {
setLoading(false);
}
}
fetchUsers();
}, []);
if (loading) return <div>Loading...</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.userName}</li>
))}
</ul>
);
}
```
### POST Request Example
```typescript
import { client } from '@/lib/api/client';
async function createUser(userData: any) {
const response = await client.POST('/api/services/app/User/Create', {
body: {
userName: 'newuser',
emailAddress: 'user@example.com',
name: 'John',
surname: 'Doe',
password: 'P@ssw0rd',
isActive: true
}
});
return response.data;
}
```
### Authentication Flow
```typescript
import { client } from '@/lib/api/client';
import { setAuthTokens, setUserInfo } from '@/lib/auth';
async function login(userNameOrEmailAddress: string, password: string) {
const response = await client.POST('/api/TokenAuth/Authenticate', {
body: {
userNameOrEmailAddress,
password
}
});
// Save tokens (client will automatically use them for subsequent requests)
setAuthTokens({
accessToken: response.data.accessToken,
encryptedAccessToken: response.data.encryptedAccessToken,
expireInSeconds: response.data.expireInSeconds
});
// Optionally save user info
setUserInfo({
id: response.data.userId,
userName: response.data.userName,
emailAddress: response.data.emailAddress
});
return response.data;
}
```
## Features
### Automatic Authentication
The client automatically adds `Authorization: Bearer <token>` to all requests using the token from `localStorage`.
### Multi-Tenancy Support
If a tenant ID is set, it's automatically added as `Abp.TenantId` header.
### ABP Response Unwrapping
ASP.NET Boilerplate wraps responses in this format:
```json
{
"result": { /* your data */ },
"success": true,
"error": null,
"unAuthorizedRequest": false,
"__abp": true
}
```
The client automatically extracts `.result` so you work directly with your data.
### Error Handling
ABP errors are automatically thrown as JavaScript errors:
```typescript
try {
await client.POST('/api/services/app/User/Create', { body: invalidData });
} catch (error) {
console.error(error.message); // ABP error message
console.error(error.validationErrors); // Validation errors if any
}
```
### Unauthorized Handling
If the backend returns `unAuthorizedRequest: true`, the client:
1. Clears the auth token
2. Redirects to `/auth/sign-in`
## Configuration
### Environment Variables
Create `.env.local`:
```bash
NEXT_PUBLIC_API_URL=https://localhost:44313
```
### Regenerating Types
When your backend DTOs change:
```bash
pnpm generate:api
```
This will update all types and ensure type-safety throughout your frontend.
## Tips
1. **Always use the client from `@/lib/api/client`**, not directly from `@/client`
2. **Server Components** are preferred - they're faster and don't require `useEffect`
3. **IntelliSense works everywhere** - TypeScript knows all endpoints, params, and responses
4. **No manual type definitions needed** - everything is auto-generated from your C# DTOs

View File

@@ -0,0 +1,119 @@
/**
* Configured API client with ASP.NET Boilerplate interceptors
*
* This client automatically:
* - Adds JWT authentication headers
* - Adds multi-tenancy headers
* - Unwraps ABP response format ({ result, success, error })
* - Handles ABP errors
*
* IMPORTANT: Run `pnpm generate:api` first to generate the client from OpenAPI spec
*/
import { client } from '@/client/client.gen';
import { getAuthToken, getTenantId } from '@/lib/auth';
/**
* ASP.NET Boilerplate response wrapper
*/
interface AbpResponse<T = unknown> {
result: T;
targetUrl: string | null;
success: boolean;
error: {
code: number;
message: string;
details: string;
validationErrors: Array<{
message: string;
members: string[];
}>;
} | null;
unAuthorizedRequest: boolean;
__abp: boolean;
}
/**
* Configure base URL
*/
client.setConfig({
baseUrl: process.env.NEXT_PUBLIC_API_URL || 'https://localhost:44313',
});
/**
* Request interceptor: Add authentication and tenant headers
*/
client.interceptors.request.use((request) => {
// Add JWT token
const token = getAuthToken();
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
// Add tenant ID for multi-tenancy
const tenantId = getTenantId();
if (tenantId) {
request.headers.set('Abp.TenantId', tenantId);
}
// Ensure content-type is set
if (!request.headers.has('Content-Type')) {
request.headers.set('Content-Type', 'application/json');
}
return request;
});
/**
* Response interceptor: Unwrap ABP response format
*/
client.interceptors.response.use((response) => {
const data = response.data;
// Check if this is an ABP-wrapped response
if (data && typeof data === 'object' && '__abp' in data) {
const abpData = data as AbpResponse;
// Handle errors
if (!abpData.success && abpData.error) {
const error = new Error(abpData.error.message || 'An error occurred');
(error as any).code = abpData.error.code;
(error as any).details = abpData.error.details;
(error as any).validationErrors = abpData.error.validationErrors;
throw error;
}
// Handle unauthorized
if (abpData.unAuthorizedRequest) {
// Clear auth and redirect to login
if (typeof window !== 'undefined') {
const { clearAuthToken } = require('@/lib/auth');
clearAuthToken();
window.location.href = '/auth/sign-in';
}
throw new Error('Unauthorized request');
}
// Unwrap the result
return {
...response,
data: abpData.result,
};
}
// Return as-is if not ABP format
return response;
});
/**
* Export configured client
*/
export { client };
/**
* Export type-safe client (after generation)
* Usage:
* import { client } from '@/lib/api/client';
* const users = await client.GET('/api/services/app/User/GetAll');
*/
export default client;

View File

@@ -0,0 +1,111 @@
/**
* Authentication helpers for ASP.NET Boilerplate JWT tokens
*/
const ACCESS_TOKEN_KEY = 'accessToken';
const ENCRYPTED_ACCESS_TOKEN_KEY = 'encryptedAccessToken';
const TENANT_ID_KEY = 'tenantId';
const USER_INFO_KEY = 'userInfo';
export interface UserInfo {
id: number;
userName: string;
name?: string;
surname?: string;
emailAddress?: string;
}
export interface AuthTokens {
accessToken: string;
encryptedAccessToken: string;
expireInSeconds: number;
}
/**
* Get the current access token
*/
export function getAuthToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
/**
* Get the encrypted access token (used for some ABP operations)
*/
export function getEncryptedAuthToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(ENCRYPTED_ACCESS_TOKEN_KEY);
}
/**
* Set authentication tokens after successful login
*/
export function setAuthTokens(tokens: AuthTokens): void {
if (typeof window === 'undefined') return;
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken);
localStorage.setItem(ENCRYPTED_ACCESS_TOKEN_KEY, tokens.encryptedAccessToken);
}
/**
* Clear all authentication data (logout)
*/
export function clearAuthToken(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(ENCRYPTED_ACCESS_TOKEN_KEY);
localStorage.removeItem(USER_INFO_KEY);
}
/**
* Check if user is authenticated
*/
export function isAuthenticated(): boolean {
return !!getAuthToken();
}
/**
* Get current tenant ID (for multi-tenancy)
*/
export function getTenantId(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(TENANT_ID_KEY);
}
/**
* Set tenant ID (for multi-tenancy)
*/
export function setTenantId(tenantId: string | number | null): void {
if (typeof window === 'undefined') return;
if (tenantId === null) {
localStorage.removeItem(TENANT_ID_KEY);
} else {
localStorage.setItem(TENANT_ID_KEY, String(tenantId));
}
}
/**
* Get stored user information
*/
export function getUserInfo(): UserInfo | null {
if (typeof window === 'undefined') return null;
const userInfoStr = localStorage.getItem(USER_INFO_KEY);
if (!userInfoStr) return null;
try {
return JSON.parse(userInfoStr) as UserInfo;
} catch {
return null;
}
}
/**
* Set user information
*/
export function setUserInfo(userInfo: UserInfo): void {
if (typeof window === 'undefined') return;
localStorage.setItem(USER_INFO_KEY, JSON.stringify(userInfo));
}

View File

@@ -1,26 +1,34 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { config } from "@/components/config";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(
bytes: number,
opts: {
decimals?: number;
sizeType?: 'accurate' | 'normal';
} = {}
) {
const { decimals = 0, sizeType = 'normal' } = opts;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const accurateSizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'];
if (bytes === 0) return '0 Byte';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${
sizeType === 'accurate'
? (accurateSizes[i] ?? 'Bytest')
: (sizes[i] ?? 'Bytes')
}`;
export function capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type GroupBy<T, K extends keyof T> = Record<string, T[]>;
export function groupBy<T, K extends keyof T>(
array: T[],
key: K
): GroupBy<T, K> {
return array.reduce((acc, item) => {
const keyValue = String(item[key]);
if (!acc[keyValue]) {
acc[keyValue] = [];
}
acc[keyValue].push(item);
return acc;
}, {} as GroupBy<T, K>);
}
export function absoluteUrl(path: string) {
return process.env.NODE_ENV === "development"
? `http://localhost:3000${path}`
: `https://${config.appUrl}${path}`;
}

View File

@@ -1,11 +1,29 @@
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']);
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Public routes that don't require authentication
const publicRoutes = ['/auth/sign-in', '/auth/sign-up', '/auth/forgot-password'];
const isPublicRoute = publicRoutes.some((route) => pathname.startsWith(route));
// Get token from cookie or check localStorage (client-side only)
// Note: In middleware, we can only check cookies, not localStorage
const token = request.cookies.get('accessToken')?.value;
// If trying to access protected route without token, redirect to sign-in
if (!isPublicRoute && !token && pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/auth/sign-in', request.url));
}
// If authenticated and trying to access auth pages, redirect to dashboard
if (isPublicRoute && token) {
return NextResponse.redirect(new URL('/dashboard/overview', request.url));
}
return NextResponse.next();
}
export default clerkMiddleware(async (auth, req: NextRequest) => {
if (isProtectedRoute(req)) await auth.protect();
});
export const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params