Skip to content

Commit

Permalink
feature: user list
Browse files Browse the repository at this point in the history
  • Loading branch information
qichunren committed Dec 4, 2024
1 parent 2a576f4 commit 27f7e54
Show file tree
Hide file tree
Showing 18 changed files with 548 additions and 11 deletions.
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,5 @@ group :test do
gem "capybara"
gem "selenium-webdriver"
end

gem "pagy", "~> 9.3.2"
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ GEM
nokogiri (1.16.8-x86_64-linux)
racc (~> 1.4)
ostruct (0.6.1)
pagy (9.3.2)
parallel (1.26.3)
parser (3.3.6.0)
ast (~> 2.4.1)
Expand Down Expand Up @@ -364,6 +365,7 @@ DEPENDENCIES
jbuilder
jwt
kamal
pagy (~> 9.3.2)
propshaft
puma (>= 5.0)
rails (~> 8.0.0)
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,38 @@ In this project, you can design pages using the conventional Rails approach with

## Development

### Prepare credentials

Use `EDITOR="code --wait"` to open/generate the credentials file **config/credentials.yml.enc** in VSCode.


credentials sample:

```
SECRET_KEY_BASE: 64 bytes hex string
```

### Setup

```
bin/setup
bin/setup --update # Optional: Update dependencies
pnpm run build # Build the frontend into app/assets/builds
pnpm run build # Build the frontend into app/assets/builds
bin/rails server
```

### Run tests

```
bin/rails test
```

### Check code quality

```
bin/rubocop -f github
```

## Changelog

- 2024-11-14: v1.0.0
Expand Down
28 changes: 28 additions & 0 deletions app/controllers/api/v1/users_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module Api
module V1
class UsersController < ApplicationController
include Authentication
include Pagy::Backend

def index
@pagy, @users = pagy(User.order(created_at: :desc),
items: params[:per_page] || 10)

render json: {
users: @users.map { |user| user_json(user) },
meta: pagy_metadata(@pagy)
}
end

private

def user_json(user)
{
id: user.id,
email: user.email,
created_at: user.created_at.iso8601
}
end
end
end
end
3 changes: 3 additions & 0 deletions app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ def authenticate_user

begin
decoded = JWT.decode(token, Rails.application.credentials.secret_key_base)[0]
puts "decoded: #{decoded.inspect}"
@current_user = User.find(decoded["user_id"])
puts "current_user: #{@current_user.inspect}"
rescue JWT::DecodeError, ActiveRecord::RecordNotFound
puts "JWT::DecodeError, ActiveRecord::RecordNotFound"
render json: { message: t(".unauthorized") }, status: :unauthorized
end
end
Expand Down
46 changes: 41 additions & 5 deletions app/frontend/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ import Login from '../pages/auth/login';
import Register from '../pages/auth/register';
import { useTranslation } from 'react-i18next';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import UsersPage from '../pages/users';
import { AuthProvider } from '@/contexts/auth-context';
import { useAuth } from '@/hooks/use-auth';
import PrivateRoute from '@/components/private-route';

const App = () => {
const AppContent = () => {
const { t, i18n } = useTranslation();
const { isAuthenticated, logout } = useAuth();

const handleLanguageChange = (value: string) => {
i18n.changeLanguage(value);
Expand All @@ -24,8 +29,24 @@ const App = () => {
<li><a href='/posts'>{t('nav.posts')}</a></li>
<li><Link to='/app/pages1'>Pages1</Link></li>
<li><Link to='/app/pages2'>Pages2</Link></li>
<li><Link to='/app/login'>{t('nav.login')}</Link></li>
<li><Link to='/app/register'>{t('nav.register')}</Link></li>
{!isAuthenticated ? (
<>
<li><Link to='/app/login'>{t('nav.login')}</Link></li>
<li><Link to='/app/register'>{t('nav.register')}</Link></li>
</>
) : (
<>
<li><Link to='/app/users'>{t('nav.users')}</Link></li>
<li>
<button
onClick={logout}
className="text-red-500 hover:text-red-700"
>
{t('nav.logout')}
</button>
</li>
</>
)}
<li>
<Select value={i18n.language} onValueChange={handleLanguageChange}>
<SelectTrigger>
Expand All @@ -45,11 +66,26 @@ const App = () => {
<Route path='/app/pages2' element={<Pages2 />} />
<Route path='/app/login' element={<Login />} />
<Route path='/app/register' element={<Register />} />

<Route
path='/app/users'
element={
<PrivateRoute>
<UsersPage />
</PrivateRoute>
}
/>
</Routes>
</div>
</Router>
);
}
};

const App = () => {
return (
<AuthProvider>
<AppContent />
</AuthProvider>
);
};

export default App;
20 changes: 20 additions & 0 deletions app/frontend/components/private-route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/use-auth';

interface PrivateRouteProps {
children: React.ReactNode;
}

const PrivateRoute = ({ children }: PrivateRouteProps) => {
const location = useLocation();
const { isAuthenticated } = useAuth();

if (!isAuthenticated) {
// 将用户重定向到登录页面,但保存他们试图访问的URL
return <Navigate to="/app/login" state={{ from: location }} replace />;
}

return <>{children}</>;
};

export default PrivateRoute;
120 changes: 120 additions & 0 deletions app/frontend/components/ui/table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import * as React from "react"

import { cn } from "@/lib/utils"

const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"

const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"

const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"

const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"

const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"

const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"

const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"

const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"

export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
46 changes: 46 additions & 0 deletions app/frontend/contexts/auth-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createContext, useEffect, useState } from 'react';

interface AuthContextType {
isAuthenticated: boolean;
token: string | null;
login: (token: string) => void;
logout: () => void;
}

export const AuthContext = createContext<AuthContextType>({
isAuthenticated: false,
token: null,
login: () => {},
logout: () => {},
});

export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
const [isAuthenticated, setIsAuthenticated] = useState(!!token);

useEffect(() => {
const storedToken = localStorage.getItem('token');
if (storedToken) {
setToken(storedToken);
setIsAuthenticated(true);
}
}, []);

const login = (newToken: string) => {
localStorage.setItem('token', newToken);
setToken(newToken);
setIsAuthenticated(true);
};

const logout = () => {
localStorage.removeItem('token');
setToken(null);
setIsAuthenticated(false);
};

return (
<AuthContext.Provider value={{ isAuthenticated, token, login, logout }}>
{children}
</AuthContext.Provider>
);
};
10 changes: 10 additions & 0 deletions app/frontend/hooks/use-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { AuthContext } from '@/contexts/auth-context';

export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
Loading

0 comments on commit 27f7e54

Please sign in to comment.