initial stash
This commit is contained in:
parent
2f021a0082
commit
0ed1a3072d
@ -1,3 +1,6 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"prettier"
|
||||
]
|
||||
}
|
||||
|
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true
|
||||
}
|
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"eslint.validate": ["typescript", "typescriptreact", "html"],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
}
|
0
components/LoginPage/LoginPage.module.scss
Normal file
0
components/LoginPage/LoginPage.module.scss
Normal file
10
components/LoginPage/LoginPage.tsx
Normal file
10
components/LoginPage/LoginPage.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export const LoginPage = () => {
|
||||
return (
|
||||
<>
|
||||
<h1>Log in</h1>
|
||||
<Link href={'/api/login'}>Log in with Icy Network</Link>
|
||||
</>
|
||||
);
|
||||
};
|
29
components/UsersPage/UsersPage.module.scss
Normal file
29
components/UsersPage/UsersPage.module.scss
Normal file
@ -0,0 +1,29 @@
|
||||
.userCard {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
.pictureWrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h2 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
.username {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
92
components/UsersPage/UsersPage.tsx
Normal file
92
components/UsersPage/UsersPage.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import styles from './UsersPage.module.scss';
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useUser from '../../lib/hooks/useUser';
|
||||
import { PaginatedResponse } from '../../lib/types/paginated-response.interface';
|
||||
import { UserListItem } from '../../lib/types/users.interfaces';
|
||||
import { Container } from '../common/Container/Container';
|
||||
import { Header } from '../common/Header/Header';
|
||||
import avatar from '../../public/avatar.png';
|
||||
import Image from 'next/image';
|
||||
import { UPLOADS_URL } from '../../lib/constants';
|
||||
import { Paginator } from '../common/Paginator/Paginator';
|
||||
|
||||
const UserCard = ({ user }: { user: UserListItem }) => (
|
||||
<div className={styles.userCard}>
|
||||
<div className={styles.pictureWrapper}>
|
||||
{user.picture ? (
|
||||
<Image
|
||||
src={`${UPLOADS_URL}/${user.picture.file}`}
|
||||
width={128}
|
||||
height={128}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<Image src={avatar} alt="" width={128} height={128} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.userInfo}>
|
||||
<h2>
|
||||
{user.display_name}{' '}
|
||||
<span className={styles.username}>@{user.username}</span>
|
||||
</h2>
|
||||
<dl>
|
||||
<dt>UUID</dt>
|
||||
<dd>{user.uuid}</dd>
|
||||
<dt>Email</dt>
|
||||
<dd>{user.email}</dd>
|
||||
<dt>Activated</dt>
|
||||
<dd>{user.activated ? 'Yes' : <b>NOT ACTIVATED</b>}</dd>
|
||||
<dt>Registered</dt>
|
||||
<dd>{new Date(user.created_at).toDateString()}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const UserList = ({
|
||||
pageIndex,
|
||||
searchTerm,
|
||||
setPage,
|
||||
}: {
|
||||
pageIndex: number;
|
||||
searchTerm: string;
|
||||
setPage: (page: number) => void;
|
||||
}) => {
|
||||
const { data } = useSWR<PaginatedResponse<UserListItem>>(
|
||||
`/api/admin/users?page=${pageIndex}${searchTerm ? `&q=${searchTerm}` : ''}`
|
||||
);
|
||||
|
||||
return data ? (
|
||||
<>
|
||||
<div className={styles.userList}>
|
||||
{data.list.map((user) => (
|
||||
<UserCard user={user} key={user.uuid} />
|
||||
))}
|
||||
<Paginator setPage={setPage} pagination={data.pagination}></Paginator>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span>Nothing found</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const UsersPage = () => {
|
||||
const { user } = useUser({ redirectTo: '/login' });
|
||||
const [pageIndex, setPageIndex] = useState(1);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header user={user}></Header>
|
||||
<Container>
|
||||
<h1>Users</h1>
|
||||
<UserList
|
||||
pageIndex={pageIndex}
|
||||
searchTerm={searchTerm}
|
||||
setPage={setPageIndex}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
5
components/common/Container/Container.module.scss
Normal file
5
components/common/Container/Container.module.scss
Normal file
@ -0,0 +1,5 @@
|
||||
.container {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
5
components/common/Container/Container.tsx
Normal file
5
components/common/Container/Container.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import styles from './Container.module.scss';
|
||||
|
||||
export const Container = ({ children }: { children: React.ReactNode }) => {
|
||||
return <main className={styles.container}>{children}</main>;
|
||||
};
|
35
components/common/Header/Header.module.scss
Normal file
35
components/common/Header/Header.module.scss
Normal file
@ -0,0 +1,35 @@
|
||||
.nav {
|
||||
display: flex;
|
||||
background-color: #2eb9ff;
|
||||
border-bottom: 4px solid #00aaff;
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
max-width: 1080px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&.alignRight {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
|
||||
&.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
a {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
91
components/common/Header/Header.tsx
Normal file
91
components/common/Header/Header.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import styles from './Header.module.scss';
|
||||
import { CurrentUserDto } from '../../../lib/types/user-response.interface';
|
||||
import { useEffect, useState } from 'react';
|
||||
import userHasPrivileges from '../../../lib/utils/has-privileges';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import conditionalClass from '../../../lib/utils/conditional-class';
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: 'Home',
|
||||
path: '/',
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
title: 'Users',
|
||||
privileges: ['admin', 'admin:user'],
|
||||
},
|
||||
{
|
||||
path: '/privileges',
|
||||
title: 'Privileges',
|
||||
privileges: ['admin', 'admin:user:privilege'],
|
||||
},
|
||||
{
|
||||
path: '/oauth2',
|
||||
title: 'OAuth2',
|
||||
privileges: [['admin', 'admin:oauth2'], 'self:oauth2'],
|
||||
},
|
||||
{
|
||||
path: '/documents',
|
||||
title: 'Documents',
|
||||
privileges: ['admin:document'],
|
||||
},
|
||||
];
|
||||
|
||||
const useAvailableNavigation = (user?: CurrentUserDto) => {
|
||||
const [stateList, setStateList] = useState(
|
||||
Array.from({ length: navItems.length }, (_, i) => i === 0)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newList: boolean[] = [];
|
||||
navItems.forEach((item, index) => {
|
||||
newList[index] = item.privileges
|
||||
? userHasPrivileges(user, ...item.privileges)
|
||||
: true;
|
||||
});
|
||||
setStateList(newList);
|
||||
}, [user]);
|
||||
|
||||
return stateList;
|
||||
};
|
||||
|
||||
export const Header = ({ user }: { user?: CurrentUserDto }) => {
|
||||
const router = useRouter();
|
||||
const privileges = useAvailableNavigation(user);
|
||||
return (
|
||||
<nav className={styles.nav}>
|
||||
<div className={styles.inner}>
|
||||
<ul>
|
||||
{navItems
|
||||
.filter((_, index) => privileges[index])
|
||||
.map((item) => (
|
||||
<li
|
||||
key={item.title}
|
||||
className={conditionalClass(
|
||||
router.pathname === item.path,
|
||||
styles.active
|
||||
)}
|
||||
>
|
||||
<Link href={item.path}>{item.title}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className={styles.alignRight}>
|
||||
<li>
|
||||
{user ? (
|
||||
<Link href={'/api/logout'}>Log out</Link>
|
||||
) : (
|
||||
<Link href={'/api/login'}>Log in</Link>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
22
components/common/Paginator/Paginator.module.scss
Normal file
22
components/common/Paginator/Paginator.module.scss
Normal file
@ -0,0 +1,22 @@
|
||||
.paginator {
|
||||
display: flex;
|
||||
margin: 0 auto;
|
||||
|
||||
.pageButton {
|
||||
appearance: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
|
||||
&.previous {
|
||||
border-top-left-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
&.next {
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
46
components/common/Paginator/Paginator.tsx
Normal file
46
components/common/Paginator/Paginator.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import styles from './Paginator.module.scss';
|
||||
import { Pagination } from '../../../lib/types/pagination.interface';
|
||||
import conditionalClass from '../../../lib/utils/conditional-class';
|
||||
|
||||
export const Paginator = ({
|
||||
pagination,
|
||||
setPage,
|
||||
}: {
|
||||
pagination: Pagination;
|
||||
setPage: (page: number) => void;
|
||||
}) => {
|
||||
// TODO: smarter page numbers
|
||||
return (
|
||||
<div className={styles.paginator}>
|
||||
<button
|
||||
className={[styles.pageButton, styles.previous].join(' ')}
|
||||
onClick={() => setPage(pagination.page - 1)}
|
||||
disabled={pagination.page <= 1}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{Array.from({ length: pagination.pageCount }, (_, i) => i + 1).map(
|
||||
(pageNum) => (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setPage(pageNum)}
|
||||
disabled={pageNum === pagination.page}
|
||||
className={[
|
||||
styles.pageButton,
|
||||
conditionalClass(pageNum === pagination.page, styles.current),
|
||||
].join(' ')}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
className={[styles.pageButton, styles.next].join(' ')}
|
||||
onClick={() => setPage(pagination.page + 1)}
|
||||
disabled={pagination.page + 1 >= pagination.pageCount}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
8
lib/api/remote.ts
Normal file
8
lib/api/remote.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { API_URL } from '../constants';
|
||||
import { CurrentUserDto } from '../types/user-response.interface';
|
||||
import { fetchBearer, fetchToken } from '../utils/fetch';
|
||||
|
||||
export const getUserInfo = async (accessToken: string) =>
|
||||
await fetchBearer<CurrentUserDto>(accessToken, `${API_URL}/user`);
|
||||
|
||||
export const getAccessToken = async (code: string) => await fetchToken(code);
|
8
lib/constants.ts
Normal file
8
lib/constants.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export const PUBLIC_URL = process.env.NEXT_PUBLIC_URL as string;
|
||||
export const SERVER_URL = process.env.SERVER_URL as string;
|
||||
export const API_URL = `${SERVER_URL}/api`;
|
||||
export const OAUTH_URL = `${SERVER_URL}/oauth2`;
|
||||
export const CLIENT_ID = process.env.CLIENT_ID as string;
|
||||
export const CLIENT_SECRET = process.env.CLIENT_SECRET as string;
|
||||
export const COOKIE_KEYS = [process.env.COOKIE_KEY] as string[];
|
||||
export const UPLOADS_URL = process.env.NEXT_PUBLIC_UPLOADS_URL as string;
|
16
lib/hooks/useHasPrivileges.ts
Normal file
16
lib/hooks/useHasPrivileges.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CurrentUserDto } from '../types/user-response.interface';
|
||||
import userHasPrivileges from '../utils/has-privileges';
|
||||
|
||||
export default function useHasPrivileges(
|
||||
user: CurrentUserDto | undefined,
|
||||
...privileges: (string | string[])[]
|
||||
) {
|
||||
const [hasPrivileges, setHasPrivileges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasPrivileges(userHasPrivileges(user, ...privileges));
|
||||
}, [user, privileges]);
|
||||
|
||||
return hasPrivileges;
|
||||
}
|
31
lib/hooks/useUser.ts
Normal file
31
lib/hooks/useUser.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CurrentUserDto } from '../types/user-response.interface';
|
||||
|
||||
export default function useUser({
|
||||
redirectTo = '',
|
||||
redirectIfFound = false,
|
||||
} = {}) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
data: user,
|
||||
mutate: mutateUser,
|
||||
error,
|
||||
} = useSWR<CurrentUserDto>('/api/user');
|
||||
|
||||
useEffect(() => {
|
||||
if (!redirectTo) return;
|
||||
|
||||
if (
|
||||
// If redirectTo is set, redirect if the user was not found.
|
||||
(redirectTo && !redirectIfFound && !user && error) ||
|
||||
// If redirectIfFound is also set, redirect if the user was found
|
||||
(redirectIfFound && user && !error)
|
||||
) {
|
||||
router.push(redirectTo);
|
||||
}
|
||||
}, [user, error, router, redirectIfFound, redirectTo]);
|
||||
|
||||
return { user, mutateUser };
|
||||
}
|
24
lib/session.ts
Normal file
24
lib/session.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
|
||||
import { getUserInfo } from './api/remote';
|
||||
import { CurrentUserDto } from './types/user-response.interface';
|
||||
import { getActiveCookie } from './utils/get-active-cookie';
|
||||
|
||||
function withUser(
|
||||
handler: (
|
||||
ctx: GetServerSidePropsContext,
|
||||
user?: CurrentUserDto
|
||||
) => Promise<GetServerSidePropsResult<any>>
|
||||
) {
|
||||
return async (ctx: GetServerSidePropsContext) => {
|
||||
const accessToken = getActiveCookie(ctx, 'authorization');
|
||||
let user: CurrentUserDto | undefined;
|
||||
if (accessToken) {
|
||||
try {
|
||||
user = await getUserInfo(accessToken);
|
||||
} catch (e) {}
|
||||
}
|
||||
return handler(ctx, user);
|
||||
};
|
||||
}
|
||||
|
||||
export default withUser;
|
6
lib/types/paginated-response.interface.ts
Normal file
6
lib/types/paginated-response.interface.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Pagination } from './pagination.interface';
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
list: T[];
|
||||
pagination: Pagination;
|
||||
}
|
6
lib/types/pagination.interface.ts
Normal file
6
lib/types/pagination.interface.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface Pagination {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
pageSize: number;
|
||||
rowCount: number;
|
||||
}
|
4
lib/types/privilege.interface.ts
Normal file
4
lib/types/privilege.interface.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface Privilege {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
5
lib/types/token-response.interface.ts
Normal file
5
lib/types/token-response.interface.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface OAuth2TokenDto {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
}
|
14
lib/types/user-response.interface.ts
Normal file
14
lib/types/user-response.interface.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export interface CurrentUserDto {
|
||||
id: number;
|
||||
uuid: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
name: string;
|
||||
preferred_username: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
email_verified: boolean;
|
||||
image?: string;
|
||||
image_file?: string;
|
||||
privileges?: string[];
|
||||
}
|
21
lib/types/users.interfaces.ts
Normal file
21
lib/types/users.interfaces.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Privilege } from './privilege.interface';
|
||||
|
||||
export interface UserPicture {
|
||||
id: number;
|
||||
file: number;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: number;
|
||||
uuid: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
activity_at: string;
|
||||
activated: boolean;
|
||||
picture?: UserPicture;
|
||||
privileges?: Privilege[];
|
||||
}
|
6
lib/utils/conditional-class.ts
Normal file
6
lib/utils/conditional-class.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export default function conditionalClass(
|
||||
condition: boolean,
|
||||
className: string
|
||||
): string | undefined {
|
||||
return condition ? className : undefined;
|
||||
}
|
32
lib/utils/fetch.ts
Normal file
32
lib/utils/fetch.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { API_URL, CLIENT_ID, CLIENT_SECRET, OAUTH_URL } from '../constants';
|
||||
import { OAuth2TokenDto } from '../types/token-response.interface';
|
||||
|
||||
export async function fetchBearer<T>(
|
||||
accessToken: string,
|
||||
url: string,
|
||||
...options: any[]
|
||||
): Promise<T> {
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
...options,
|
||||
}).then((res) => res.json());
|
||||
}
|
||||
|
||||
export async function fetchToken(code: string): Promise<OAuth2TokenDto> {
|
||||
return fetch(`${OAUTH_URL}/token`, {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${CLIENT_ID}:${CLIENT_SECRET}`
|
||||
).toString('base64')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
}),
|
||||
}).then((res) => res.json());
|
||||
}
|
17
lib/utils/get-active-cookie.ts
Normal file
17
lib/utils/get-active-cookie.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
|
||||
import { getCookie } from './get-cookie';
|
||||
|
||||
export const getActiveCookie = (
|
||||
ctx: GetServerSidePropsContext,
|
||||
cookieName: string
|
||||
): string | undefined => {
|
||||
const setCookies = ctx.res.getHeader('set-cookie') as string[];
|
||||
const fallback = ctx.req.cookies[cookieName];
|
||||
const entry = setCookies?.find((item) => item.startsWith(cookieName));
|
||||
if (entry === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
return getCookie(cookieName, entry) || fallback;
|
||||
};
|
14
lib/utils/get-cookie.ts
Normal file
14
lib/utils/get-cookie.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export function getCookie(
|
||||
cname: string,
|
||||
cookie: string | undefined
|
||||
): string | undefined {
|
||||
if (!cookie) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const name = cname + '=';
|
||||
return decodeURIComponent(cookie)
|
||||
.split(';')
|
||||
.find((entry) => entry.startsWith(name))
|
||||
?.substring(name.length);
|
||||
}
|
28
lib/utils/has-privileges.ts
Normal file
28
lib/utils/has-privileges.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { CurrentUserDto } from '../types/user-response.interface';
|
||||
|
||||
export default function userHasPrivileges(
|
||||
user: CurrentUserDto | undefined,
|
||||
...privileges: (string | string[])[]
|
||||
): boolean {
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userPrivileges = user?.privileges || [];
|
||||
const withOrLogic = privileges.some((entry) => Array.isArray(entry));
|
||||
if (withOrLogic) {
|
||||
return privileges.some((entry) => {
|
||||
if (Array.isArray(entry)) {
|
||||
return entry.every((item) =>
|
||||
userPrivileges.find((name) => name === item)
|
||||
);
|
||||
} else {
|
||||
return userPrivileges.find((name) => name === entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return privileges.every((item) =>
|
||||
userPrivileges.find((name) => name === item)
|
||||
);
|
||||
}
|
52
lib/utils/swr-fetcher.ts
Normal file
52
lib/utils/swr-fetcher.ts
Normal file
@ -0,0 +1,52 @@
|
||||
export default async function fetchJson<JSON = unknown>(
|
||||
input: RequestInfo,
|
||||
init?: RequestInit
|
||||
): Promise<JSON> {
|
||||
const response = await fetch(input, { ...init, credentials: 'include' });
|
||||
|
||||
// if the server replies, there's always some data in json
|
||||
// if there's a network error, it will throw at the previous line
|
||||
const data = await response.json();
|
||||
|
||||
// response.ok is true when res.status is 2xx
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Response/ok
|
||||
if (response.ok) {
|
||||
return data;
|
||||
}
|
||||
|
||||
throw new FetchError({
|
||||
message: response.statusText,
|
||||
response,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export class FetchError extends Error {
|
||||
response: Response;
|
||||
data: {
|
||||
message: string;
|
||||
};
|
||||
constructor({
|
||||
message,
|
||||
response,
|
||||
data,
|
||||
}: {
|
||||
message: string;
|
||||
response: Response;
|
||||
data: {
|
||||
message: string;
|
||||
};
|
||||
}) {
|
||||
// Pass remaining arguments (including vendor specific ones) to parent constructor
|
||||
super(message);
|
||||
|
||||
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, FetchError);
|
||||
}
|
||||
|
||||
this.name = 'FetchError';
|
||||
this.response = response;
|
||||
this.data = data ?? { message: message };
|
||||
}
|
||||
}
|
@ -2,6 +2,9 @@
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
}
|
||||
images: {
|
||||
domains: ['localhost', '127.0.0.1', 'icynet.eu'],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig
|
||||
module.exports = nextConfig;
|
||||
|
6053
package-lock.json
generated
Normal file
6053
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -9,16 +9,24 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookies": "^0.8.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
"next": "12.2.5",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
"react-dom": "18.2.0",
|
||||
"swr": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookies": "^0.7.7",
|
||||
"@types/http-proxy": "^1.17.9",
|
||||
"@types/node": "18.7.13",
|
||||
"@types/react": "18.0.17",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"eslint": "8.23.0",
|
||||
"eslint-config-next": "12.2.5",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"prettier": "^2.7.1",
|
||||
"sass": "^1.54.5",
|
||||
"typescript": "4.8.2"
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,21 @@
|
||||
import '../styles/globals.css'
|
||||
import type { AppProps } from 'next/app'
|
||||
import '../styles/globals.scss';
|
||||
import type { AppProps } from 'next/app';
|
||||
import { SWRConfig } from 'swr';
|
||||
import fetchJson from '../lib/utils/swr-fetcher';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher: fetchJson,
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Component {...pageProps} />
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyApp
|
||||
export default MyApp;
|
||||
|
13
pages/_document.tsx
Normal file
13
pages/_document.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Head, Html, Main, NextScript } from 'next/document';
|
||||
// The stylesheet is for Univia-pro font from Adobe
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
39
pages/api/[...path].ts
Normal file
39
pages/api/[...path].ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { proxy } from '../../server/proxy';
|
||||
|
||||
import Cookies from 'cookies';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { COOKIE_KEYS } from '../../lib/constants';
|
||||
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// removes the api prefix from url
|
||||
// req.url = req.url!.replace(/^\/api/, '');
|
||||
|
||||
const cookies = new Cookies(req, res, { keys: COOKIE_KEYS });
|
||||
const authorization = cookies.get('authorization', { signed: true });
|
||||
|
||||
// don't forwards the cookies to the target server
|
||||
req.headers.cookie = '';
|
||||
|
||||
if (authorization) {
|
||||
req.headers.authorization = `Bearer ${authorization}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* if an error occurs in the proxy, we will reject the promise.
|
||||
* it is so important. if you don't reject the promise,
|
||||
* you're facing the stalled requests issue.
|
||||
*/
|
||||
proxy.once('error', reject);
|
||||
|
||||
proxy.web(req, res);
|
||||
});
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
24
pages/api/callback.ts
Normal file
24
pages/api/callback.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getAccessToken } from '../../lib/api/remote';
|
||||
|
||||
import Cookies from 'cookies';
|
||||
import { COOKIE_KEYS } from '../../lib/constants';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.query.code) {
|
||||
// TODO: parse state
|
||||
const getAuth = await getAccessToken(req.query.code as string);
|
||||
const cookies = new Cookies(req, res, { keys: COOKIE_KEYS });
|
||||
if (getAuth) {
|
||||
cookies.set('authorization', getAuth.access_token, {
|
||||
expires: new Date(Date.now() + getAuth.expires_in * 1000),
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
signed: true,
|
||||
});
|
||||
}
|
||||
res.redirect('/');
|
||||
}
|
||||
}
|
15
pages/api/login.ts
Normal file
15
pages/api/login.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { CLIENT_ID, OAUTH_URL, PUBLIC_URL } from '../../lib/constants';
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: 'code',
|
||||
redirect_uri: `${PUBLIC_URL}/api/callback`,
|
||||
scope: 'management',
|
||||
});
|
||||
|
||||
// TODO: generate state
|
||||
|
||||
res.redirect(`${OAUTH_URL}/authorize?${params.toString()}`);
|
||||
}
|
16
pages/api/logout.ts
Normal file
16
pages/api/logout.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import Cookies from 'cookies';
|
||||
import { COOKIE_KEYS } from '../../lib/constants';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
const cookies = new Cookies(req, res, { keys: COOKIE_KEYS });
|
||||
cookies.set('authorization', undefined, {
|
||||
expires: new Date(0),
|
||||
signed: true,
|
||||
});
|
||||
res.redirect('/');
|
||||
}
|
@ -1,72 +1,16 @@
|
||||
import type { NextPage } from 'next'
|
||||
import Head from 'next/head'
|
||||
import Image from 'next/image'
|
||||
import styles from '../styles/Home.module.css'
|
||||
import type { NextPage } from 'next';
|
||||
import { Container } from '../components/common/Container/Container';
|
||||
import { Header } from '../components/common/Header/Header';
|
||||
import useUser from '../lib/hooks/useUser';
|
||||
|
||||
const Home: NextPage = () => {
|
||||
const { user } = useUser({ redirectTo: '/login' });
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<>
|
||||
<Header user={user}></Header>
|
||||
<Container>Welcome back, {user?.display_name}!</Container>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
<main className={styles.main}>
|
||||
<h1 className={styles.title}>
|
||||
Welcome to <a href="https://nextjs.org">Next.js!</a>
|
||||
</h1>
|
||||
|
||||
<p className={styles.description}>
|
||||
Get started by editing{' '}
|
||||
<code className={styles.code}>pages/index.tsx</code>
|
||||
</p>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<a href="https://nextjs.org/docs" className={styles.card}>
|
||||
<h2>Documentation →</h2>
|
||||
<p>Find in-depth information about Next.js features and API.</p>
|
||||
</a>
|
||||
|
||||
<a href="https://nextjs.org/learn" className={styles.card}>
|
||||
<h2>Learn →</h2>
|
||||
<p>Learn about Next.js in an interactive course with quizzes!</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/vercel/next.js/tree/canary/examples"
|
||||
className={styles.card}
|
||||
>
|
||||
<h2>Examples →</h2>
|
||||
<p>Discover and deploy boilerplate example Next.js projects.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
>
|
||||
<h2>Deploy →</h2>
|
||||
<p>
|
||||
Instantly deploy your Next.js site to a public URL with Vercel.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Powered by{' '}
|
||||
<span className={styles.logo}>
|
||||
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
|
||||
</span>
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
||||
export default Home;
|
||||
|
19
pages/login.tsx
Normal file
19
pages/login.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import withUser from '../lib/session';
|
||||
import { LoginPage } from '../components/LoginPage/LoginPage';
|
||||
|
||||
export default LoginPage;
|
||||
|
||||
export const getServerSideProps = withUser(async (ctx, user) => {
|
||||
if (user) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/',
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
});
|
3
pages/users.tsx
Normal file
3
pages/users.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import { UsersPage } from '../components/UsersPage/UsersPage';
|
||||
|
||||
export default UsersPage;
|
BIN
public/avatar.png
Normal file
BIN
public/avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.9 KiB |
10
server/proxy.ts
Normal file
10
server/proxy.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import httpProxy from 'http-proxy';
|
||||
|
||||
export const proxy = httpProxy.createProxyServer({
|
||||
/**
|
||||
* Get the actual back-end service url from env variables.
|
||||
* We shouldn't prefix the env variable with NEXT_PUBLIC_* to avoid exposing it to the client.
|
||||
*/
|
||||
target: process.env.SERVER_URL,
|
||||
autoRewrite: false,
|
||||
});
|
Reference in New Issue
Block a user