diff --git a/.gitignore b/.gitignore index 88b6f0d..ae3981f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ yarn-error.log* # typescript *.tsbuildinfo + +# editors +.vscode diff --git a/components/dialog.tsx b/components/dialog.tsx new file mode 100644 index 0000000..79aed16 --- /dev/null +++ b/components/dialog.tsx @@ -0,0 +1,22 @@ +import { Center, VStack, Text } from "@chakra-ui/react" + +export type FullScreenDialogProps = { + title: String; + children?: JSX.Element | JSX.Element[]; +} + +export const FullScreenDialog = ({ title, children }: FullScreenDialogProps) => ( +
+ + {title} + + {children} + +
+) + diff --git a/components/error_boundary.tsx b/components/error_boundary.tsx new file mode 100644 index 0000000..4030e47 --- /dev/null +++ b/components/error_boundary.tsx @@ -0,0 +1,58 @@ +import React, { ErrorInfo } from "react"; +import { FullScreenDialog } from "./dialog"; +import { Center, Flex, Link, Text, VStack } from "@chakra-ui/react"; + +type ErrorBoundaryProps = { + children?: JSX.Element | JSX.Element[]; +} + +type ErrorState = { + errorMessage?: string | JSX.Element; +} + +export default class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props) + this.state = {} + } + + static getDerivedStateFromError(error: Error) { + return { errorMessage: ErrorBoundary.translateErrorMessage(error) }; + } + + static translateErrorMessage(error: Error): string | JSX.Element { + switch (error.message) { + case "Network Error": return "Błąd połączenia z serwerem." + case "Request failed with status code 401": return ( + Nie masz dostępu do tej strony będąc niezalogowanym! + Kliknij tutaj aby przejść do logowania. + ) + default: return error.message + } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.log("Uncaught error thrown: " + error + ". Error info: " + errorInfo) + } + + render() { + if (this.state.errorMessage) { + return ( + + Coś poszło nie tak. Wiadomość błędu: + {this.state.errorMessage} + + ); + } else { + return this.props.children; + } + } +} + +// idea by David Barral: +// https://medium.com/trabe/catching-asynchronous-errors-in-react-using-error-boundaries-5e8a5fd7b971 +// thanks +export const usePromiseError = () => { + const [_, setError] = React.useState(null); + return React.useCallback(err => setError(() => { throw err }), [setError]); +}; diff --git a/components/navbar.tsx b/components/navbar.tsx index 71d7118..59af9ff 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -3,9 +3,15 @@ import { Box, Button, ButtonProps, Center, Flex, IconButton, LayoutProps, Link, LinkProps, Menu, MenuButton, MenuItem, MenuList, Spacer, + Image, + Text, } from "@chakra-ui/react"; +import { useEffect, useState } from "react"; +import { getProfileByUserId, Profile } from "../lib/profile"; +import { getSession } from "../lib/auth"; +import { usePromiseError } from "./error_boundary"; -const LOGIN_DISCORD_URL = "/api/auth/discord" +const LOGIN_DISCORD_URL = "/auth/discord" type NavItem = { title: string; @@ -32,18 +38,35 @@ const NAV_ITEMS: Array = [ }, ] +const NavBar = () => { + // if profile -> loaded profile for current session successfully + // if null -> not logged in + // if undefined -> page is loading / fetching profile data from api + const [profile, setProfile] = useState(undefined) + const throwError = usePromiseError() + useEffect(() => { + let session = getSession() + if (session) { + getProfileByUserId(session.userId) + .then(setProfile) + .catch(throwError) + } else { + setProfile(null) + } + }, []) + + return ( +
+ + + + + +
+ ); +} -const NavBar = () => ( -
- - - - - -
-) - -const DesktopNavBar = () => ( +const DesktopNavBar = ({ profile }: { profile: Profile | null | undefined }) => ( @@ -62,14 +85,15 @@ const DesktopNavBar = () => ( - + ) -const MobileNavBar = () => ( +const MobileNavBar = ({ profile }: { profile: Profile | null | undefined }) => ( ( {NAV_ITEMS.map(item => ())} - @@ -92,7 +118,9 @@ const MobileNavBar = () => ( - + ) @@ -117,12 +145,73 @@ const Logo = (props: LinkProps) => ( ) +type MenuProps = { + profile: Profile | null | undefined + buttonProps?: ButtonProps +} + +const UserMenu = ({ profile, buttonProps }: MenuProps) => { + if (profile === undefined) { + return <> + } else if (profile === null) { + return + } else { + return + } +} + const LoginWithDiscord = (props: ButtonProps) => ( - ) +const LoggedInUser = ({ profile }: { profile: Profile }) => { + return ( + + + + + {profile.name} + + } + variant='ghost'> + + + + + + + + ); +} + export default NavBar; diff --git a/components/section.tsx b/components/section.tsx index 1b17e72..acd7106 100644 --- a/components/section.tsx +++ b/components/section.tsx @@ -1,21 +1,29 @@ -import { Box, Container, Flex, Heading, SimpleGrid, Text } from "@chakra-ui/react"; +import { Box, BoxProps, Container, Flex, Heading, SimpleGrid, Text } from "@chakra-ui/react"; import * as CSS from "csstype"; type SectionProps = { - id: string; + id?: string; title: String; description: String; + small?: boolean; children?: JSX.Element | JSX.Element[]; } -export const Section = ({ id, title, description, children }: SectionProps) => ( - +export const Section = ({ id, title, description, small, children, ...boxProps }: SectionProps & BoxProps) => ( + - {title} - {description} + {title} + + {description} + {children} @@ -39,7 +47,7 @@ export const SectionCard = ({ minHeight, children }: SectionCardProps) => ( {children} diff --git a/lib/activitylog.tsx b/lib/activitylog.tsx new file mode 100644 index 0000000..65cbaa3 --- /dev/null +++ b/lib/activitylog.tsx @@ -0,0 +1,6 @@ +export type ActivityLog = { + id: number; + createdAt: number; + name: string; + data: any; +} diff --git a/lib/auth.tsx b/lib/auth.tsx new file mode 100644 index 0000000..97c6239 --- /dev/null +++ b/lib/auth.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react" +import client from "./client" + +export const authenticatedFetcher = (url: string) => + client + .get(url, { + headers: { + "Authorization": `Bearer ${getSession()?.accessToken}`, + } + }) + .then(res => res.data) + +export type Session = { + id: string; + userId: number; + accessToken: string; + // unix time (seconds) + expiresAt: number; +} + +// Represents session info without authorization token. +// Used in session list. +export type SessionMeta = { + id: string; + ip: string; + userAgent: string; + lastAccessedAt: number; +} + +let session: Session | null = null + +export const useSession = () => { + // todo + const [session, setSession] = useState() + useEffect(() => setSession(getSession()), []) + return session +} + +export function getSession(): Session | null { + if (session != null) { + return session + } else { + return session = getSessionFromStorage() + } +} + +function getSessionFromStorage(): Session | null { + const sessionJson = localStorage.getItem("session") + if (sessionJson != null) { + const session = JSON.parse(sessionJson) as Session + // invalidate session if expired + const currentUnixSeconds = Date.now() / 1000 + if (currentUnixSeconds >= session.expiresAt) { + removeSessionFromStorage() + return null + } else { + return session + } + } else { + return null + } +} + +export function setCurrentSession(newSession: Session) { + localStorage.setItem("session", JSON.stringify(newSession)) + session = newSession +} + +function removeSessionFromStorage() { + localStorage.removeItem("session") +} + +export async function getAuthUrl(): Promise { + type AuthUrlResponse = { + url: string + } + const r = await client.get("/auth/discord") + return r.data.url +} + +export const login = (code: string) => client + .post("/auth/discord", { + "code": code, + }) + .then(response => response.data) + +export async function logout(): Promise { + const currSession = getSession() + if (currSession) { + const invalidateLocalSession = () => { + removeSessionFromStorage() + session = null + } + + return client + .post("/auth/logout", null, { + headers: { + "Authorization": `Bearer ${currSession.accessToken}`, + } + }) + .then(invalidateLocalSession) + .catch(ex => { + if (ex.response?.status == 401) { + invalidateLocalSession() + } else { + throw ex; + } + }) + } else { + return; + } +} diff --git a/lib/client.tsx b/lib/client.tsx new file mode 100644 index 0000000..fc2db22 --- /dev/null +++ b/lib/client.tsx @@ -0,0 +1,5 @@ +import axios from "axios" + +export default axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_URL, +}) diff --git a/lib/error_response.tsx b/lib/error_response.tsx new file mode 100644 index 0000000..1e01b76 --- /dev/null +++ b/lib/error_response.tsx @@ -0,0 +1,11 @@ +export class ErrorResponse { + message: string; + + constructor(message: string) { + this.message = message; + } + + public static fromJson(json: any) { + return new ErrorResponse(json.error_message) + } +} \ No newline at end of file diff --git a/lib/profile.tsx b/lib/profile.tsx new file mode 100644 index 0000000..8c70d51 --- /dev/null +++ b/lib/profile.tsx @@ -0,0 +1,12 @@ +import client from "./client" + +export type Profile = { + name: string + avatarUrl: string +} + +export async function getProfileByUserId(userId: number): Promise { + return client + .get("/profile/" + userId) + .then(response => response.data) +} diff --git a/package-lock.json b/package-lock.json index 6a222a3..63d32cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,16 @@ "@chakra-ui/react": "^1.7.3", "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", + "@types/ua-parser-js": "^0.7.36", + "axios": "^0.25.0", + "focus-visible": "^5.2.0", "framer-motion": "^4.1.17", "next": "12.0.7", "react": "17.0.2", "react-device-detect": "^2.1.2", - "react-dom": "17.0.2" + "react-dom": "17.0.2", + "swr": "^1.2.1", + "ua-parser-js": "^1.0.2" }, "devDependencies": { "@iconify/react": "^3.1.0", @@ -2320,6 +2325,11 @@ "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, + "node_modules/@types/ua-parser-js": { + "version": "0.7.36", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz", + "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==" + }, "node_modules/@types/warning": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", @@ -2713,6 +2723,14 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", + "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", + "dependencies": { + "follow-redirects": "^1.14.7" + } + }, "node_modules/axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -3119,12 +3137,54 @@ } }, "node_modules/cross-fetch": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.4.tgz", - "integrity": "sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", "dev": true, "dependencies": { - "node-fetch": "2.6.1" + "node-fetch": "2.6.7" + } + }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/cross-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", + "dev": true + }, + "node_modules/cross-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", + "dev": true + }, + "node_modules/cross-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" } }, "node_modules/cross-spawn": { @@ -4289,6 +4349,30 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" }, + "node_modules/focus-visible": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/focus-visible/-/focus-visible-5.2.0.tgz", + "integrity": "sha512-Rwix9pBtC1Nuy5wysTmKy+UjbDJpIfg8eHjw0rjZ1mX4GNLz1Bmd16uDpI3Gk1i70Fgcs8Csg2lPm8HULFg9DQ==" + }, + "node_modules/follow-redirects": { + "version": "1.14.8", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz", + "integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreach": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", @@ -5328,9 +5412,9 @@ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, "node_modules/nanoid": { - "version": "3.1.30", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.30.tgz", - "integrity": "sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz", + "integrity": "sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -6019,6 +6103,24 @@ "react-dom": ">= 0.14.0 < 18.0.0" } }, + "node_modules/react-device-detect/node_modules/ua-parser-js": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz", + "integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "engines": { + "node": "*" + } + }, "node_modules/react-dom": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", @@ -6596,6 +6698,14 @@ "node": ">=4" } }, + "node_modules/swr": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-1.2.1.tgz", + "integrity": "sha512-1cuWXqJqXcFwbgONGCY4PHZ8v05009JdHsC3CIC6u7d00kgbMswNr1sHnnhseOBxtzVqcCNpOHEgVDciRer45w==", + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -6741,9 +6851,9 @@ } }, "node_modules/ua-parser-js": { - "version": "0.7.31", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz", - "integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.2.tgz", + "integrity": "sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg==", "funding": [ { "type": "opencollective", @@ -8701,6 +8811,11 @@ "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, + "@types/ua-parser-js": { + "version": "0.7.36", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz", + "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==" + }, "@types/warning": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", @@ -8970,6 +9085,14 @@ "integrity": "sha512-WKTW1+xAzhMS5dJsxWkliixlO/PqC4VhmO9T4juNYcaTg9jzWiJsou6m5pxWYGfigWbwzJWeFY6z47a+4neRXA==", "dev": true }, + "axios": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", + "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", + "requires": { + "follow-redirects": "^1.14.7" + } + }, "axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -9318,12 +9441,45 @@ } }, "cross-fetch": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.4.tgz", - "integrity": "sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", "dev": true, "requires": { - "node-fetch": "2.6.1" + "node-fetch": "2.6.7" + }, + "dependencies": { + "node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dev": true, + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", + "dev": true + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", + "dev": true + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "dev": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } } }, "cross-spawn": { @@ -10232,6 +10388,16 @@ } } }, + "focus-visible": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/focus-visible/-/focus-visible-5.2.0.tgz", + "integrity": "sha512-Rwix9pBtC1Nuy5wysTmKy+UjbDJpIfg8eHjw0rjZ1mX4GNLz1Bmd16uDpI3Gk1i70Fgcs8Csg2lPm8HULFg9DQ==" + }, + "follow-redirects": { + "version": "1.14.8", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz", + "integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==" + }, "foreach": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", @@ -11006,9 +11172,9 @@ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, "nanoid": { - "version": "3.1.30", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.30.tgz", - "integrity": "sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ==" + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz", + "integrity": "sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==" }, "natural-compare": { "version": "1.4.0", @@ -11521,6 +11687,13 @@ "integrity": "sha512-N42xttwez3ECgu4KpOL2ICesdfoz8NCBfmc1rH9FRYSjH7NmMyANPSrQ3EvAtJyj/6TzJNhrANSO38iXjCB2Ug==", "requires": { "ua-parser-js": "^0.7.30" + }, + "dependencies": { + "ua-parser-js": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz", + "integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==" + } } }, "react-dom": { @@ -11930,6 +12103,12 @@ "has-flag": "^3.0.0" } }, + "swr": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-1.2.1.tgz", + "integrity": "sha512-1cuWXqJqXcFwbgONGCY4PHZ8v05009JdHsC3CIC6u7d00kgbMswNr1sHnnhseOBxtzVqcCNpOHEgVDciRer45w==", + "requires": {} + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -12043,9 +12222,9 @@ "dev": true }, "ua-parser-js": { - "version": "0.7.31", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz", - "integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.2.tgz", + "integrity": "sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg==" }, "unbox-primitive": { "version": "1.0.1", diff --git a/package.json b/package.json index 736926d..9c4164b 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,16 @@ "@chakra-ui/react": "^1.7.3", "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", + "@types/ua-parser-js": "^0.7.36", + "axios": "^0.25.0", + "focus-visible": "^5.2.0", "framer-motion": "^4.1.17", "next": "12.0.7", "react": "17.0.2", "react-device-detect": "^2.1.2", - "react-dom": "17.0.2" + "react-dom": "17.0.2", + "swr": "^1.2.1", + "ua-parser-js": "^1.0.2" }, "devDependencies": { "@iconify/react": "^3.1.0", diff --git a/pages/_app.tsx b/pages/_app.tsx index e175ef0..bb9b07f 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,14 +1,24 @@ import type { AppProps } from 'next/app' import { CSSReset, ChakraProvider } from '@chakra-ui/react' -import theme from '../styles/theme' +import theme, { GlobalStyles } from '../styles/theme' import Fonts from '../styles/fonts' +import ErrorBoundary from '../components/error_boundary' +import 'focus-visible/dist/focus-visible' +import { Global } from '@emotion/react' +import { SWRConfig } from 'swr' +import { authenticatedFetcher } from '../lib/auth' function MyApp({ Component, pageProps }: AppProps) { return ( + - + + + + + ) } diff --git a/pages/auth/discord.tsx b/pages/auth/discord.tsx new file mode 100644 index 0000000..0368a35 --- /dev/null +++ b/pages/auth/discord.tsx @@ -0,0 +1,145 @@ +import { NextPage } from "next" +import { Link, Progress, Text, VStack } from '@chakra-ui/react' +import Head from "next/head" +import { useRouter } from "next/router" +import { ReactElement, useEffect, useState } from "react" +import { getAuthUrl, getSession, login, Session, setCurrentSession } from "../../lib/auth" +import { FullScreenDialog } from "../../components/dialog" + +// login flow: +// 1. naviagte to /auth/discord +// 2. navigate to discord oauth page (from "/api/auth/discord" url) +// 3. discord oauth redirects back to "/auth/discord" but with "code" param +// 4. try register/login by sending discord access "code" to the /api/auth/discord via post json +// 5. handle response (2xx status - succes -> redirect to home page after 2 seconds of login success message) + +type State = + | { type: "dismounted" } + | { type: "generating_url" } + | { type: "authorizing" } + | { type: "auth_success", session: Session } + | { type: "failure", error: any } + +const DiscordAuth: NextPage = () => { + const [state, setState] = useState({ type: "dismounted" }) + const { query, isReady } = useRouter() + + const redirectToOAuth = () => { + setState({ type: "generating_url" }) + getAuthUrl() + .then(url => window.location.href = url) + .catch(err => setState({ type: "failure", error: err })) + } + + const authorize = (code: string) => { + setState({ type: "authorizing" }) + login(code) + .then(session => { + setCurrentSession(session) + setState({ type: "auth_success", session: session }) + setTimeout(() => window.location.href = "/", 2000) + }) + .catch(err => setState({ type: "failure", error: err })) + } + + useEffect(() => { + let session = getSession() + if (session) { // if already authenticated + window.location.href = "/" // redirect to home page + } else if (isReady && state.type == "dismounted") { // if router is ready && page is in right state + const code = query["code"]?.toString() + if (code == null || code == "") { + const discordError = query["error"]?.toString() + if (discordError == null || discordError == "") { + redirectToOAuth() + } else { + setState({ type: "failure", error: { type: "discord_error", code: discordError } }) + } + } else { + authorize(code) + } + } + }, [isReady]) + + return ( + <> + + Autoryzacja - BuzkaaClicker.pl + + +
+ + {render(state, redirectToOAuth)} + +
+ + ) +} + +function render(state: State, onReload: () => void): ReactElement { + switch (state.type) { + case "generating_url": + return <> + Uzyskiwanie łącza do autoryzacji... + + + case "authorizing": + return <> + Trwa autoryzacja... + + + case "auth_success": + return Zalogowano pomyślnie + case "failure": + return + default: + return Wczytywanie... + } +} + +const AuthError = ({ err, onRetry }: { err: any, onRetry: () => void }) => { + const retryLink = Czy chcesz spróbować jeszcze raz? + + if (err.response) { + const translated = (() => { + switch (err.response.data.error_message) { + case "invalid code": return "Nieprawidłowy kod" + case "missing email": return "Nie uzyskano dostępu do e-mail. " + + "Przypisz e-mail do konta discord, zweryfikuj go i spróbuj ponownie." + case "discord guild join unauthorized": return "Brak dostępu do dołączenia do serwera discord! " + + "Jest to wymagane dlatego, że jest to nasz preferowany środek komunikacji z klientami." + default: return err.response.data.error_message + } + })() + return ( + + {translated} + {retryLink} + + ) + } else if (err.type == "discord_error") { + const translated = (() => { + switch (err.code) { + case "access_denied": return "Odrzuciłeś/aś autoryzację konta discord." + } + })() + if (translated == null) { + console.log("Unknown discord error: " + err.code + ` (${JSON.stringify(err)})`) + } + return ( + + {translated || "Wystąpił nieznany błąd podczas autoryzacji konta discord."} + {retryLink} + + ) + } else { + return ( + + Wystąpił nieznany błąd: {err?.message} + {retryLink} + + ) + } +} + +export default DiscordAuth diff --git a/pages/auth/logout.tsx b/pages/auth/logout.tsx new file mode 100644 index 0000000..8fbd577 --- /dev/null +++ b/pages/auth/logout.tsx @@ -0,0 +1,57 @@ +import { NextPage } from "next" +import Head from "next/head" +import { ReactElement, useEffect, useState } from "react" +import { FullScreenDialog } from "../../components/dialog" +import { logout } from "../../lib/auth" +import { Text } from "@chakra-ui/react" + +type State = + | { type: "dismounted" } + | { type: "invalidating_session" } + | { type: "invalidated_session" } + | { type: "failure", error: any } + + +const Logout: NextPage = () => { + const [state, setState] = useState({ type: "dismounted" }) + + const tryLogout = () => { + setState({ type: "invalidating_session" }) + + logout() + .then(_ => setState({ type: "invalidated_session" })) + .then(_ => setTimeout(() => window.location.href = "/", 2000)) + .catch(err => setState({ type: "failure", error: err })) + } + + useEffect(() => tryLogout(), []) + + return ( + <> + + Pa :/ - BuzkaaClicker.pl + + +
+ + {render(state, tryLogout)} + +
+ + ) +} + +function render(state: State, onReload: () => void): ReactElement { + switch (state.type) { + case "invalidating_session": + return Niszczenie sesji... + case "invalidated_session": + return Unieważniono sesję pomyślnie + case "failure": + return Błąd podczas unieważniania sesji: {state.error?.message} + default: + return <> + } +} + +export default Logout diff --git a/pages/index.tsx b/pages/index.tsx index 7a6c656..e6b3099 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -16,7 +16,6 @@ const Home: NextPage = () => ( Strona Główna - BuzkaaClicker.pl - diff --git a/pages/settings/_security.tsx b/pages/settings/_security.tsx new file mode 100644 index 0000000..24bdc26 --- /dev/null +++ b/pages/settings/_security.tsx @@ -0,0 +1,377 @@ +import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons' +import { Box, Text, HStack, VStack, Button, Spacer, Progress, Menu, MenuButton, IconButton, MenuList, Flex, Link, MenuItem, useToast, UseToastOptions, Tooltip, AlertDialog, AlertDialogOverlay, AlertDialogHeader, AlertDialogContent, AlertDialogBody, AlertDialogFooter, BoxProps } from '@chakra-ui/react' +import { useEffect, useRef, useState } from 'react' +import useSWR, { useSWRConfig } from 'swr' +import UAParser from 'ua-parser-js' +import { Section } from '../../components/section' +import { ActivityLog } from '../../lib/activitylog' +import { getSession, SessionMeta, useSession } from '../../lib/auth' +import client from '../../lib/client' + +const timeFormat = new Intl.RelativeTimeFormat('pl'); +const unixSeconds = () => (new Date()).getTime() / 1000; + +export const SecurityPanel = () => { + return ( + + + + + + ) +} + +// List of sessions. +const Sessions = () => { + const [sessions, setSessions] = useState() + let { data, error, mutate } = useSWR('/sessions', null, { + refreshInterval: 5000, + refreshWhenHidden: false, + refreshWhenOffline: false, + }) + useEffect(() => setSessions(data), [data]) + if (error) { + throw error + } + return ( +
+ + mutate()} /> + + {sessions != null ? + sessions + .sort((a, b) => b.lastAccessedAt - a.lastAccessedAt) + .map((session) => + setSessions(sessions.filter(s => s.id != session.id))} + />) + : + + } + +
+ ) +} + +const LogoffAllSessions = ({ onInvalidate }: { onInvalidate: () => void }) => { + const [isDialogOpen, setDialogOpen] = useState(false) + const onConfirm = () => { + setDialogOpen(false) + sendRequest() + } + const onDialogClose = () => setDialogOpen(false) + const cancelRef = useRef(null) + const toast = useToast() + const currentSession = useSession() + + const sendRequest = () => { + client + .delete("/sessions/other", { headers: { "Authorization": "Bearer " + currentSession?.accessToken } }) + .then(() => toast({ + title: "Wylogowano pomyślnie.", + status: "success", + duration: 3000, + isClosable: true, + })) + .catch(ex => handleLogoffException(ex, toast)) + .then(() => onInvalidate()) + } + + return ( + <> + setDialogOpen(true)} + > + Wyloguj wszystkie sesje oprócz aktualnej + + + + + + + Wyloguj wszystkie sesje oprócz aktualnej. + + + + Jesteś pewny/a? Nie możesz cofnąć tej akcji! + + + + + + + + + + + ) +} + +// Session card. +const Session = ({ session, loggedOff }: { session: SessionMeta, loggedOff: () => void }) => { + const toast = useToast() + const currentSession = useSession() + + const [loggingOff, setLoggingOff] = useState(false) + const logoffSession = (session: SessionMeta) => { + setLoggingOff(true) + + // if this is current session + if (session.id == currentSession?.id) { + // redirect to full page goodbye dialog + window.location.href = "/auth/logout" + } else { + client + .delete("/session/" + encodeURIComponent(session.id), { + headers: { + "Authorization": "Bearer " + getSession()?.accessToken, + } + }) + .then(loggedOff) + .catch(ex => handleLogoffException(ex, toast)) + } + } + + const [blurActive, setBlurActive] = useState(true) + + const uaParser = UAParser(session.userAgent); + return ( + + + + + + {uaParser.browser.name} {uaParser.browser.version} +   •   + {correctOsName(uaParser.os.name)} {uaParser.os.version} + + + + + IP: + setBlurActive(!blurActive)}> + {session.ip} + + + + + + + + + ⋮} + variant='ghost'> + + + + logoffSession(session)}> + Wyloguj tę sesję + + + + + + ) +} + +// Toggleable blur wrapper. +const Blurred = ({ active, onClick, children }: { active: boolean, onClick: () => void, children: JSX.Element }) => { + return ( + + onClick()} + > + {children} + + + ) +} + +const handleLogoffException = (ex: Error, toast: (useToast?: UseToastOptions) => void) => { + console.log("Could not delete session: " + ex + ".") + toast({ + title: 'Wystąpił nieoczekiwany błąd podczas wylogowywania z sesji.', + description: ex.message, + status: 'error', + duration: 6000, + isClosable: true, + }) +} + +// +// activity logs +// + +const Logs = () => { + const [blurred, setBlurred] = useState(true) + const [logs, setLogs] = useState() + let { data, error, mutate, isValidating } = useSWR('/activities', null, { + refreshInterval: 0, + }) + useEffect(() => setLogs(data), [data]) + + return ( +
+ {logs == null ? + dsadas + : + logs.map(l => + setBlurred(!blurred)} /> + ) + } +
+ ) +} + +const Log = ({ info, blurred, toggleBlur }: { info: ActivityLog, blurred: boolean, toggleBlur: () => void }) => { + const [expanded, setExpanded] = useState(false) + const { name, details } = humanLog(info) + + return ( + + + + + + + toggleBlur()}> + {details} + + + + ) +} + +const humanLog = (info: ActivityLog) => { + switch (info.name) { + case "session_created": + return { + name: "Zalogowano do konta", + details: "Zalogowano do konta z adresu " + info.data.ip + ".\n\n" + + "Sesja: " + info.data.session_id, + } + case "session_changed_user_agent": + return { + name: "Zmieniono wersję przeglądarki lub aplikacji", + details: "Nagłówek UserAgent zmienił się z: \n" + info.data.previous_user_agent + "\nna:\n" + + info.data.new_user_agent + ".\n\n" + + "Sesja: " + info.data.session_id, + } + case "session_changed_ip": + return { + name: "Zmieniono adres IP przypisany do sesji.", + details: "Adres zmienił się z: " + info.data.previous_ip + " na: " + info.data.new_ip + ".\n\n" + + "Sesja: " + info.data.session_id, + } + default: + return { name: info.name } + } +} + +// +// shared widgets +// + +const EntryBox = ({ disabled, children, ...boxProps }: { disabled?: boolean, children: React.ReactNode } & BoxProps) => { + return ( + {children} + ) +} + +// Last activity time widget. +const Activity = ({ activeAt: activeAt, current }: { activeAt: number, current: boolean }) => { + const snapshotTimeDiff = () => humanTimeDiff(Math.floor(activeAt - unixSeconds())); + const [activity, setActivity] = useState(snapshotTimeDiff()); + + useEffect(() => { + if (current) { + setActivity("Aktualna sesja") + return undefined + } else { + let timerId = setInterval(() => setActivity(snapshotTimeDiff()), 1000) + return () => clearInterval(timerId) + } + }, [current]) + + return ( + + {activity} + + ) +} + +// +// utilities +// + +// moze jest to built in moze nie nie wiem nie znalazlem gotowca +const humanTimeDiff = (seconds: number) => { + const absSeconds = Math.abs(seconds) + if (absSeconds < 60) { + return timeFormat.format(seconds, "seconds") + } else if (absSeconds < 60 * 60) { + return timeFormat.format(Math.floor(seconds / 60), "minutes") + } else if (absSeconds < 60 * 60 * 24) { + return timeFormat.format(Math.floor(seconds / 60 / 60), "hours") + } else { + return timeFormat.format(Math.floor(seconds / 60 / 60 / 24), "days") + } +} + +// important fix +// https://github.com/faisalman/ua-parser-js/issues/491 +const correctOsName = (osName?: string) => osName == "Mac OS" ? "macOS" : osName diff --git a/pages/settings/index.tsx b/pages/settings/index.tsx new file mode 100644 index 0000000..491a103 --- /dev/null +++ b/pages/settings/index.tsx @@ -0,0 +1,52 @@ +import { Center, Flex, Heading, TabList, TabPanel, TabPanels, Tabs, Tab, VStack } from '@chakra-ui/react' +import type { NextPage } from 'next' +import Head from 'next/head' +import NavBar from '../../components/navbar' +import { SecurityPanel } from './_security' + +const SettingsHome: NextPage = () => ( + <> + + Ustawienia - BuzkaaClicker.pl + + + + +
+
+ + + + Ustawienia + + Konto + Bezpieczeństwo + + + + + +

kąto

+
+ + + + +

two!

+
+
+
+
+
+
+ +) + +const BTab = ({ children }: { children: string }) => + {children} + +export default SettingsHome diff --git a/public/static/favicon.ico b/public/favicon.ico similarity index 100% rename from public/static/favicon.ico rename to public/favicon.ico diff --git a/styles/theme.tsx b/styles/theme.tsx index 109b542..531f3bb 100644 --- a/styles/theme.tsx +++ b/styles/theme.tsx @@ -1,4 +1,13 @@ import { extendTheme } from '@chakra-ui/react' +import { css } from '@emotion/react'; + +// source https://medium.com/@keeganfamouss/accessibility-on-demand-with-chakra-ui-and-focus-visible-19413b1bc6f9 +export const GlobalStyles = css` + .js-focus-visible :focus:not([data-focus-visible-added]) { + outline: none; + box-shadow: none; + } +`; const theme = extendTheme({ colors: { @@ -67,6 +76,16 @@ const theme = extendTheme({ transform: "translateY(-0.1em)" }, }, + "overlay": { + _focus: { + boxShadow: "0", + radius: "0", + }, + _hover: { + color: "#AAA", + bg: "#111", + }, + }, "primary": { bg: "#3970C2", borderRadius: "0", @@ -85,13 +104,28 @@ const theme = extendTheme({ boxShadow: "0px 4px 0px #0000001C", }, }, - "discordLogin": { + "userMenu": { borderRadius: "8", bg: "#000", color: "#fff", fontWeight: "900", padding: "0.875rem 1.313rem 0.875rem 1.313rem", - textTransform: "uppercase", + letterSpacing: "-0.05rem", + _hover: { + bg: "#222222", + color: "#AAA", + transform: "translateY(-0.1em)" + }, + _focus: { + boxShadow: "0px 4px 0px #0000001C", + }, + }, + "flat": { + borderRadius: "8", + bg: "#111", + color: "#fff", + fontWeight: "900", + padding: "0.875rem 1.313rem 0.875rem 1.313rem", letterSpacing: "-0.05rem", _hover: { bg: "#222222",