From 2a64cd258ca48a6ec9bb8d215b1840e111b30e98 Mon Sep 17 00:00:00 2001 From: makindotcc <9150636+makindotcc@users.noreply.github.com> Date: Sat, 15 Jan 2022 18:34:00 +0100 Subject: [PATCH 1/5] Auth flow --- components/navbar.tsx | 2 +- lib/client.tsx | 5 ++ lib/error_response.tsx | 11 +++ lib/session.tsx | 34 +++++++++ package-lock.json | 2 +- package.json | 2 +- pages/auth/discord.tsx | 128 ++++++++++++++++++++++++++++++++ pages/index.tsx | 1 - public/{static => }/favicon.ico | Bin 9 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 lib/client.tsx create mode 100644 lib/error_response.tsx create mode 100644 lib/session.tsx create mode 100644 pages/auth/discord.tsx rename public/{static => }/favicon.ico (100%) diff --git a/components/navbar.tsx b/components/navbar.tsx index 71d7118..e44109e 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -5,7 +5,7 @@ import { Menu, MenuButton, MenuItem, MenuList, Spacer, } from "@chakra-ui/react"; -const LOGIN_DISCORD_URL = "/api/auth/discord" +const LOGIN_DISCORD_URL = "/auth/discord" type NavItem = { title: string; 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/session.tsx b/lib/session.tsx new file mode 100644 index 0000000..9a27b26 --- /dev/null +++ b/lib/session.tsx @@ -0,0 +1,34 @@ +import client from "./client" + +export type Session = { + userId: number; + accessToken: string; + expiresAt: number; +} + +export function getSession(): Session | null { + const sessionJson = localStorage.getItem("session") + if (sessionJson != null) { + return JSON.parse(sessionJson) + } else { + return null + } +} + +export function setSession(session: Session) { + localStorage.setItem("session", JSON.stringify(session)) +} + +export function removeSession() { + localStorage.removeItem("session") +} + +export function getAuthUrl(): Promise { + type AuthUrlResponse = { + url: string + } + console.log('test123312123') + return client + .get("/auth/discord") + .then(r => r.data.url) +} diff --git a/package-lock.json b/package-lock.json index 6a222a3..faf7391 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12224,4 +12224,4 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 736926d..1d4bbf5 100644 --- a/package.json +++ b/package.json @@ -26,4 +26,4 @@ "eslint-config-next": "12.0.7", "typescript": "4.5.3" } -} +} \ No newline at end of file diff --git a/pages/auth/discord.tsx b/pages/auth/discord.tsx new file mode 100644 index 0000000..25edb53 --- /dev/null +++ b/pages/auth/discord.tsx @@ -0,0 +1,128 @@ +import { NextPage } from "next" +import { Center, 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, Session, setSession } from "../../lib/session" +import api from "../../lib/client" + +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 => { + setSession(session) + setState({ type: "auth_success", session: session }) + setTimeout(() => window.location.href = "/", 2000) + }) + .catch(err => setState({ type: "failure", error: err })) + } + + useEffect(() => { + if (!isReady || state.type != "dismounted") { + return + } + const code = query["code"]?.toString() + if (code == null || code == "") { + redirectToOAuth() + } else { + authorize(code) + } + }, [isReady]) + + return ( + <> + + Autoryzacja - BuzkaaClicker.pl + + +
+
+ + Logowanie + + {render(state, redirectToOAuth)} + +
+
+ + ) +} + +const login = (code: string) => api + .post("/auth/discord", { + "code": code, + }) + .then(response => response.data) + + +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." + } + })() + return ( + + {translated} + {retryLink} + + ) + } else { + return ( + + Wystąpił nieznany błąd: {JSON.stringify(err)} + {retryLink} + + ) + } +} + +export default DiscordAuth diff --git a/pages/index.tsx b/pages/index.tsx index b744991..0eab4bc 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -16,7 +16,6 @@ const Home: NextPage = () => ( Strona Główna - BuzkaaClicker.pl - 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 From 4b061c198d57f4780a213af16774a89d2f8b4787 Mon Sep 17 00:00:00 2001 From: makindotcc <9150636+makindotcc@users.noreply.github.com> Date: Thu, 20 Jan 2022 15:40:24 +0100 Subject: [PATCH 2/5] Sessions & error handling --- components/dialog.tsx | 22 +++++++ components/error_boundary.tsx | 54 ++++++++++++++++ components/navbar.tsx | 113 +++++++++++++++++++++++++++++----- lib/auth.tsx | 75 ++++++++++++++++++++++ lib/profile.tsx | 12 ++++ lib/session.tsx | 34 ---------- pages/_app.tsx | 5 +- pages/auth/discord.tsx | 77 +++++++++++++---------- pages/auth/logout.tsx | 57 +++++++++++++++++ styles/theme.tsx | 3 +- 10 files changed, 369 insertions(+), 83 deletions(-) create mode 100644 components/dialog.tsx create mode 100644 components/error_boundary.tsx create mode 100644 lib/auth.tsx create mode 100644 lib/profile.tsx delete mode 100644 lib/session.tsx create mode 100644 pages/auth/logout.tsx 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..8f0d83e --- /dev/null +++ b/components/error_boundary.tsx @@ -0,0 +1,54 @@ +import React, { ErrorInfo } from "react"; +import { FullScreenDialog } from "./dialog"; +import { Text } from "@chakra-ui/react"; + +type ErrorBoundaryProps = { + children?: JSX.Element | JSX.Element[]; +} + +type ErrorState = { + errorMessage?: string; +} + +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 { + switch (error.message) { + case "Network Error": return "Błąd połączenia z serwerem." + 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 e44109e..062ad08 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -3,7 +3,13 @@ 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 = "/auth/discord" @@ -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 }) => ( ( - + ) @@ -117,12 +143,69 @@ 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/lib/auth.tsx b/lib/auth.tsx new file mode 100644 index 0000000..03b8a34 --- /dev/null +++ b/lib/auth.tsx @@ -0,0 +1,75 @@ +import client from "./client" + +export type Session = { + userId: number; + accessToken: string; + // unix time (seconds) + expiresAt: number; +} + +let session: Session | null = null + +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 function getAuthUrl(): Promise { + type AuthUrlResponse = { + url: string + } + return client + .get("/auth/discord") + .then(r => 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) { + client + .post("/auth/logout", null, { + headers: { + "Authorization": `Bearer ${currSession.accessToken}`, + } + }) + .then(r => { + removeSessionFromStorage() + session = null + }) + } +} 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/lib/session.tsx b/lib/session.tsx deleted file mode 100644 index 9a27b26..0000000 --- a/lib/session.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import client from "./client" - -export type Session = { - userId: number; - accessToken: string; - expiresAt: number; -} - -export function getSession(): Session | null { - const sessionJson = localStorage.getItem("session") - if (sessionJson != null) { - return JSON.parse(sessionJson) - } else { - return null - } -} - -export function setSession(session: Session) { - localStorage.setItem("session", JSON.stringify(session)) -} - -export function removeSession() { - localStorage.removeItem("session") -} - -export function getAuthUrl(): Promise { - type AuthUrlResponse = { - url: string - } - console.log('test123312123') - return client - .get("/auth/discord") - .then(r => r.data.url) -} diff --git a/pages/_app.tsx b/pages/_app.tsx index e175ef0..5f1fcea 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -2,13 +2,16 @@ import type { AppProps } from 'next/app' import { CSSReset, ChakraProvider } from '@chakra-ui/react' import theme from '../styles/theme' import Fonts from '../styles/fonts' +import ErrorBoundary from '../components/error_boundary' function MyApp({ Component, pageProps }: AppProps) { return ( - + + + ) } diff --git a/pages/auth/discord.tsx b/pages/auth/discord.tsx index 25edb53..04b0e58 100644 --- a/pages/auth/discord.tsx +++ b/pages/auth/discord.tsx @@ -1,10 +1,17 @@ import { NextPage } from "next" -import { Center, Link, Progress, Text, VStack } from '@chakra-ui/react' +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, Session, setSession } from "../../lib/session" -import api from "../../lib/client" +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" } @@ -28,7 +35,7 @@ const DiscordAuth: NextPage = () => { setState({ type: "authorizing" }) login(code) .then(session => { - setSession(session) + setCurrentSession(session) setState({ type: "auth_success", session: session }) setTimeout(() => window.location.href = "/", 2000) }) @@ -36,14 +43,21 @@ const DiscordAuth: NextPage = () => { } useEffect(() => { - if (!isReady || state.type != "dismounted") { - return - } - const code = query["code"]?.toString() - if (code == null || code == "") { - redirectToOAuth() - } else { - authorize(code) + 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]) @@ -54,29 +68,14 @@ const DiscordAuth: NextPage = () => {
-
- - Logowanie - - {render(state, redirectToOAuth)} - -
+ + {render(state, redirectToOAuth)} +
) } -const login = (code: string) => api - .post("/auth/discord", { - "code": code, - }) - .then(response => response.data) - - function render(state: State, onReload: () => void): ReactElement { switch (state.type) { case "generating_url": @@ -107,6 +106,7 @@ const AuthError = ({ err, onRetry }: { err: any, onRetry: () => void }) => { 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." + default: return err.response.data.error_message } })() return ( @@ -115,10 +115,25 @@ const AuthError = ({ err, onRetry }: { err: any, onRetry: () => void }) => { {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: {JSON.stringify(err)} + Wystąpił nieznany błąd: {err?.message} {retryLink} ) 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/styles/theme.tsx b/styles/theme.tsx index 109b542..894b9ac 100644 --- a/styles/theme.tsx +++ b/styles/theme.tsx @@ -85,13 +85,12 @@ 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", From 03f5c86a711d6f57481d4866b1ee3b7bac85d726 Mon Sep 17 00:00:00 2001 From: makindotcc <9150636+makindotcc@users.noreply.github.com> Date: Thu, 20 Jan 2022 19:48:56 +0100 Subject: [PATCH 3/5] mobile navbar bugfix; join guild error translation --- components/navbar.tsx | 8 ++++++- pages/auth/discord.tsx | 2 ++ pages/settings/index.tsx | 48 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 pages/settings/index.tsx diff --git a/components/navbar.tsx b/components/navbar.tsx index 062ad08..ad51cf9 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -103,7 +103,9 @@ const MobileNavBar = ({ profile }: { profile: Profile | null | undefined }) => ( {NAV_ITEMS.map(item => ())} -
@@ -199,6 +201,10 @@ const LoggedInUser = ({ profile }: { profile: Profile }) => { + void }) => { 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 } })() diff --git a/pages/settings/index.tsx b/pages/settings/index.tsx new file mode 100644 index 0000000..9b9ec91 --- /dev/null +++ b/pages/settings/index.tsx @@ -0,0 +1,48 @@ +import { Box, Center, Flex, Tab, TabList, TabPanel, TabPanels, Tabs, VStack } from '@chakra-ui/react' +import type { NextPage } from 'next' +import Head from 'next/head' +import NavBar from '../../components/navbar' + +const SettingsHome: NextPage = () => ( + <> + + Ustawienia - BuzkaaClicker.pl + + + + +
+
+ + + + Ustawienia + Bezpieczeństwo + + + +

My kontra oni!

+

kim sa oni!

+

one!

+

one!

+

My kontra oni!

+

kim sa oni!

+

one!

+

one!

+

My kontra oni!

+

kim sa oni!

+

one!

+

one!

+
+ +

two!

+
+
+
+
+
+
+ +) + +export default SettingsHome From 0957a9a8c312c1044d8c53ddb78862d015e3308f Mon Sep 17 00:00:00 2001 From: makindotcc <9150636+makindotcc@users.noreply.github.com> Date: Fri, 11 Feb 2022 17:37:10 +0100 Subject: [PATCH 4/5] sessions --- .gitignore | 3 + components/error_boundary.tsx | 10 +- components/navbar.tsx | 2 +- components/section.tsx | 18 ++- lib/activitylog.tsx | 6 + lib/auth.tsx | 53 ++++++-- package-lock.json | 223 +++++++++++++++++++++++++++++---- package.json | 9 +- pages/_app.tsx | 11 +- pages/settings/_security.tsx | 228 ++++++++++++++++++++++++++++++++++ pages/settings/index.tsx | 40 +++--- styles/theme.tsx | 9 ++ 12 files changed, 549 insertions(+), 63 deletions(-) create mode 100644 lib/activitylog.tsx create mode 100644 pages/settings/_security.tsx 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/error_boundary.tsx b/components/error_boundary.tsx index 8f0d83e..4030e47 100644 --- a/components/error_boundary.tsx +++ b/components/error_boundary.tsx @@ -1,13 +1,13 @@ import React, { ErrorInfo } from "react"; import { FullScreenDialog } from "./dialog"; -import { Text } from "@chakra-ui/react"; +import { Center, Flex, Link, Text, VStack } from "@chakra-ui/react"; type ErrorBoundaryProps = { children?: JSX.Element | JSX.Element[]; } type ErrorState = { - errorMessage?: string; + errorMessage?: string | JSX.Element; } export default class ErrorBoundary extends React.Component { @@ -20,9 +20,13 @@ export default class ErrorBoundary extends React.Component + Nie masz dostępu do tej strony będąc niezalogowanym! + Kliknij tutaj aby przejść do logowania. + ) default: return error.message } } diff --git a/components/navbar.tsx b/components/navbar.tsx index ad51cf9..59af9ff 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -57,7 +57,7 @@ const NavBar = () => { return (
- + diff --git a/components/section.tsx b/components/section.tsx index 1b17e72..f131570 100644 --- a/components/section.tsx +++ b/components/section.tsx @@ -2,20 +2,28 @@ import { Box, Container, Flex, Heading, SimpleGrid, Text } from "@chakra-ui/reac 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 }: SectionProps) => ( - {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 index 03b8a34..97c6239 100644 --- a/lib/auth.tsx +++ b/lib/auth.tsx @@ -1,14 +1,41 @@ +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 @@ -43,13 +70,12 @@ function removeSessionFromStorage() { localStorage.removeItem("session") } -export function getAuthUrl(): Promise { +export async function getAuthUrl(): Promise { type AuthUrlResponse = { url: string } - return client - .get("/auth/discord") - .then(r => r.data.url) + const r = await client.get("/auth/discord") + return r.data.url } export const login = (code: string) => client @@ -61,15 +87,26 @@ export const login = (code: string) => client export async function logout(): Promise { const currSession = getSession() if (currSession) { - client + const invalidateLocalSession = () => { + removeSessionFromStorage() + session = null + } + + return client .post("/auth/logout", null, { headers: { "Authorization": `Bearer ${currSession.accessToken}`, } }) - .then(r => { - removeSessionFromStorage() - session = null + .then(invalidateLocalSession) + .catch(ex => { + if (ex.response?.status == 401) { + invalidateLocalSession() + } else { + throw ex; + } }) + } else { + return; } } diff --git a/package-lock.json b/package-lock.json index faf7391..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", @@ -12224,4 +12403,4 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 1d4bbf5..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", @@ -26,4 +31,4 @@ "eslint-config-next": "12.0.7", "typescript": "4.5.3" } -} \ No newline at end of file +} diff --git a/pages/_app.tsx b/pages/_app.tsx index 5f1fcea..bb9b07f 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,16 +1,23 @@ 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/settings/_security.tsx b/pages/settings/_security.tsx new file mode 100644 index 0000000..902c57b --- /dev/null +++ b/pages/settings/_security.tsx @@ -0,0 +1,228 @@ +import { Box, Text, HStack, VStack, Button, Spacer, Progress, Menu, MenuButton, IconButton, MenuList, Flex, Link, MenuItem, useToast, UseToastOptions } from '@chakra-ui/react' +import { useEffect, 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' + +export const SecurityPanel = () => { + return ( + + + + + + ) +} + +const Logs = () => { + const toast = useToast() + const [logs, setLogs] = useState() + let { data, error, mutate, isValidating } = useSWR('/activities', null) + useEffect(() => setLogs(data), [data]) + + return ( +
+ {logs == null ? + dsadas + : + logs.map(l => ) + } +
+ ) +} + +const Log = ({ info }: { info: ActivityLog }) => { + const humanName = "" + + return {info.name} +} + +const Sessions = () => { + const toast = useToast() + const [sessions, setSessions] = useState() + let { data, error, mutate, isValidating } = useSWR('/sessions', null) + useEffect(() => setSessions(data), [data]) + const currentSession = useSession() + + const logoffAllExceptCurrent = () => { + client + .delete("/sessions/other", { headers: { "Authorization": "Bearer " + currentSession?.accessToken } }) + .catch(ex => handleLogoffException(ex, toast)) + .then(() => mutate()) + } + + if (error) { + throw error + } + return ( +
+ + + Wyloguj wszystkie sesje oprócz aktualnej + + + {sessions != null && !isValidating ? + sessions + .sort((a, b) => b.lastAccessedAt - a.lastAccessedAt) + .map((session) => + setSessions(sessions.filter(s => s.id != session.id))} + />) + : + + } + +
+ ) +} + +const sessionTimeFormat = new Intl.RelativeTimeFormat('pl'); + +const unixSeconds = () => (new Date()).getTime() / 1000; + +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 uaParser = UAParser(session.userAgent); + return ( + + + + + + {uaParser.browser.name} {uaParser.browser.version} +   •   + {correctOsName(uaParser.os.name)} {uaParser.os.version} + + + + + IP: {session.ip} + + + + + + + + ⋮} + variant='ghost'> + + + + logoffSession(session)}> + Wyloguj tę sesję + + + + + + ) +} + +const SessionActivity = ({ session, current }: { session: SessionMeta, current: boolean }) => { + const snapshotTimeDiff = () => humanTimeDiff(Math.floor(session.lastAccessedAt - 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} + ) +} + +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, + }) +} + +// 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 sessionTimeFormat.format(seconds, "seconds") + } else if (absSeconds < 60 * 60) { + return sessionTimeFormat.format(Math.floor(seconds / 60), "minutes") + } else if (absSeconds < 60 * 60 * 24) { + return sessionTimeFormat.format(Math.floor(seconds / 60 / 60), "hours") + } else { + return sessionTimeFormat.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 + +const humanLogName = (name: string) => { + switch (name) { + case "created_session": + return { name: "Zalogowano do konta", details: "Z adresu "} + default: + return { name: name } + } +} diff --git a/pages/settings/index.tsx b/pages/settings/index.tsx index 9b9ec91..bd6d173 100644 --- a/pages/settings/index.tsx +++ b/pages/settings/index.tsx @@ -1,7 +1,8 @@ -import { Box, Center, Flex, Tab, TabList, TabPanel, TabPanels, Tabs, VStack } from '@chakra-ui/react' +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 = () => ( <> @@ -12,27 +13,23 @@ const SettingsHome: NextPage = () => (
-
- +
+ - - Ustawienia - Bezpieczeństwo - - + + Ustawienia + + Konto + Bezpieczeństwo + + + + + +

kąto

+
-

My kontra oni!

-

kim sa oni!

-

one!

-

one!

-

My kontra oni!

-

kim sa oni!

-

one!

-

one!

-

My kontra oni!

-

kim sa oni!

-

one!

-

one!

+

two!

@@ -45,4 +42,7 @@ const SettingsHome: NextPage = () => ( ) +const BTab = ({ children }: { children: string }) => + {children} + export default SettingsHome diff --git a/styles/theme.tsx b/styles/theme.tsx index 894b9ac..188b9bd 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: { From 9c28ebdb813cdce6e031280eb442bef692cf81f1 Mon Sep 17 00:00:00 2001 From: makindotcc <9150636+makindotcc@users.noreply.github.com> Date: Wed, 16 Feb 2022 14:06:39 +0100 Subject: [PATCH 5/5] auth settings --- components/section.tsx | 6 +- pages/settings/_security.tsx | 331 +++++++++++++++++++++++++---------- pages/settings/index.tsx | 12 +- styles/theme.tsx | 26 +++ 4 files changed, 277 insertions(+), 98 deletions(-) diff --git a/components/section.tsx b/components/section.tsx index f131570..acd7106 100644 --- a/components/section.tsx +++ b/components/section.tsx @@ -1,4 +1,4 @@ -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 = { @@ -9,8 +9,8 @@ type SectionProps = { children?: JSX.Element | JSX.Element[]; } -export const Section = ({ id, title, description, small, children }: SectionProps) => ( - +export const Section = ({ id, title, description, small, children, ...boxProps }: SectionProps & BoxProps) => ( + (new Date()).getTime() / 1000; + export const SecurityPanel = () => { return ( - + @@ -17,47 +21,15 @@ export const SecurityPanel = () => { ) } -const Logs = () => { - const toast = useToast() - const [logs, setLogs] = useState() - let { data, error, mutate, isValidating } = useSWR('/activities', null) - useEffect(() => setLogs(data), [data]) - - return ( -
- {logs == null ? - dsadas - : - logs.map(l => ) - } -
- ) -} - -const Log = ({ info }: { info: ActivityLog }) => { - const humanName = "" - - return {info.name} -} - +// List of sessions. const Sessions = () => { - const toast = useToast() const [sessions, setSessions] = useState() - let { data, error, mutate, isValidating } = useSWR('/sessions', null) + let { data, error, mutate } = useSWR('/sessions', null, { + refreshInterval: 5000, + refreshWhenHidden: false, + refreshWhenOffline: false, + }) useEffect(() => setSessions(data), [data]) - const currentSession = useSession() - - const logoffAllExceptCurrent = () => { - client - .delete("/sessions/other", { headers: { "Authorization": "Bearer " + currentSession?.accessToken } }) - .catch(ex => handleLogoffException(ex, toast)) - .then(() => mutate()) - } - if (error) { throw error } @@ -68,16 +40,9 @@ const Sessions = () => { description="Lista Twoich aktywnych sesji" > - - Wyloguj wszystkie sesje oprócz aktualnej - + mutate()} /> - {sessions != null && !isValidating ? + {sessions != null ? sessions .sort((a, b) => b.lastAccessedAt - a.lastAccessedAt) .map((session) => @@ -94,10 +59,72 @@ const Sessions = () => { ) } -const sessionTimeFormat = new Intl.RelativeTimeFormat('pl'); +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 unixSeconds = () => (new Date()).getTime() / 1000; + 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() @@ -122,30 +149,28 @@ const Session = ({ session, loggedOff }: { session: SessionMeta, loggedOff: () = } } + 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: {session.ip} - - + IP: + setBlurActive(!blurActive)}> + {session.ip} + + + @@ -166,12 +191,150 @@ const Session = ({ session, loggedOff }: { session: SessionMeta, loggedOff: () = + + ) +} + +// 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 SessionActivity = ({ session, current }: { session: SessionMeta, current: boolean }) => { - const snapshotTimeDiff = () => humanTimeDiff(Math.floor(session.lastAccessedAt - unixSeconds())); +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(() => { @@ -185,44 +348,30 @@ const SessionActivity = ({ session, current }: { session: SessionMeta, current: }, [current]) return ( - {activity} + + {activity} + ) } -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, - }) -} +// +// 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 sessionTimeFormat.format(seconds, "seconds") + return timeFormat.format(seconds, "seconds") } else if (absSeconds < 60 * 60) { - return sessionTimeFormat.format(Math.floor(seconds / 60), "minutes") + return timeFormat.format(Math.floor(seconds / 60), "minutes") } else if (absSeconds < 60 * 60 * 24) { - return sessionTimeFormat.format(Math.floor(seconds / 60 / 60), "hours") + return timeFormat.format(Math.floor(seconds / 60 / 60), "hours") } else { - return sessionTimeFormat.format(Math.floor(seconds / 60 / 60 / 24), "days") + 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 - -const humanLogName = (name: string) => { - switch (name) { - case "created_session": - return { name: "Zalogowano do konta", details: "Z adresu "} - default: - return { name: name } - } -} diff --git a/pages/settings/index.tsx b/pages/settings/index.tsx index bd6d173..491a103 100644 --- a/pages/settings/index.tsx +++ b/pages/settings/index.tsx @@ -14,11 +14,15 @@ const SettingsHome: NextPage = () => (
- - - + + + Ustawienia - + Konto Bezpieczeństwo diff --git a/styles/theme.tsx b/styles/theme.tsx index 188b9bd..531f3bb 100644 --- a/styles/theme.tsx +++ b/styles/theme.tsx @@ -76,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", @@ -110,6 +120,22 @@ const theme = extendTheme({ 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", + color: "#AAA", + transform: "translateY(-0.1em)" + }, + _focus: { + boxShadow: "0px 4px 0px #0000001C", + }, + }, }, defaultProps: { variant: "primary",