changes: - Removed clerk auth
- added defautl basic login - types generated by ioenapi shcema
This commit is contained in:
60
CHANGELOG.md
60
CHANGELOG.md
@@ -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
111
CLAUDE.md
@@ -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
|
||||
|
||||
6
src/ASPBaseOIDC.Web.Ui/.gitignore
vendored
6
src/ASPBaseOIDC.Web.Ui/.gitignore
vendored
@@ -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
|
||||
|
||||
90
src/ASPBaseOIDC.Web.Ui/QUICK_START.md
Normal file
90
src/ASPBaseOIDC.Web.Ui/QUICK_START.md
Normal 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
|
||||
144
src/ASPBaseOIDC.Web.Ui/TROUBLESHOOTING.md
Normal file
144
src/ASPBaseOIDC.Web.Ui/TROUBLESHOOTING.md
Normal 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
|
||||
25
src/ASPBaseOIDC.Web.Ui/openapi-ts.config.ts
Normal file
25
src/ASPBaseOIDC.Web.Ui/openapi-ts.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
2032
src/ASPBaseOIDC.Web.Ui/pnpm-lock.yaml
generated
2032
src/ASPBaseOIDC.Web.Ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
}
|
||||
|
||||
162
src/ASPBaseOIDC.Web.Ui/src/app/login/page.tsx
Normal file
162
src/ASPBaseOIDC.Web.Ui/src/app/login/page.tsx
Normal 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'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;
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
11
src/ASPBaseOIDC.Web.Ui/src/components/config.ts
Normal file
11
src/ASPBaseOIDC.Web.Ui/src/components/config.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
14
src/ASPBaseOIDC.Web.Ui/src/components/logo.tsx
Normal file
14
src/ASPBaseOIDC.Web.Ui/src/components/logo.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
162
src/ASPBaseOIDC.Web.Ui/src/contexts/auth-context.tsx
Normal file
162
src/ASPBaseOIDC.Web.Ui/src/contexts/auth-context.tsx
Normal 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;
|
||||
}
|
||||
@@ -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't have an account?{' '}
|
||||
<a
|
||||
href='/auth/sign-up'
|
||||
className='text-primary hover:underline font-medium'
|
||||
>
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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{' '}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
224
src/ASPBaseOIDC.Web.Ui/src/lib/api/README.md
Normal file
224
src/ASPBaseOIDC.Web.Ui/src/lib/api/README.md
Normal 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
|
||||
119
src/ASPBaseOIDC.Web.Ui/src/lib/api/client.ts
Normal file
119
src/ASPBaseOIDC.Web.Ui/src/lib/api/client.ts
Normal 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;
|
||||
111
src/ASPBaseOIDC.Web.Ui/src/lib/auth.ts
Normal file
111
src/ASPBaseOIDC.Web.Ui/src/lib/auth.ts
Normal 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));
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user