diff --git a/src/actions/Types.ts b/src/actions/Types.ts index 8adbe2664..dcdff4409 100644 --- a/src/actions/Types.ts +++ b/src/actions/Types.ts @@ -1,11 +1,9 @@ -import type { AuthStatus } from '@/auth/getUser' -import type { ServerErrorCode, ErrorMessage } from '@/services/error' - -export type ActionErrorCode = ServerErrorCode | AuthStatus +import type { ErrorMessage, ErrorCode } from '@/services/error' export type ActionReturnError = { success: false, - errorCode: ActionErrorCode, + errorCode: ErrorCode, + httpCode: number, error?: ErrorMessage[], } diff --git a/src/actions/dots/create.ts b/src/actions/dots/create.ts new file mode 100644 index 000000000..2261881ce --- /dev/null +++ b/src/actions/dots/create.ts @@ -0,0 +1,5 @@ +'use server' +import { Action } from '@/actions/Action' +import { Dots } from '@/services/dots' + +export const createDotAction = Action(Dots.create) diff --git a/src/actions/dots/read.ts b/src/actions/dots/read.ts new file mode 100644 index 000000000..7789d41ad --- /dev/null +++ b/src/actions/dots/read.ts @@ -0,0 +1,7 @@ +'use server' +import { ActionNoData } from '@/actions/Action' +import { Dots } from '@/services/dots' + +export const readDotPage = ActionNoData(Dots.readPage) + +export const readDotWrapperForUser = ActionNoData(Dots.readWrapperForUser) diff --git a/src/actions/error.ts b/src/actions/error.ts index e22990506..2f26be65c 100644 --- a/src/actions/error.ts +++ b/src/actions/error.ts @@ -1,11 +1,21 @@ -import type { ErrorMessage } from '@/services/error' +import { errorCodes, type ErrorCode, type ErrorMessage } from '@/services/error' +import type { AuthStatus } from '@/auth/getUser' import type { SafeParseError } from 'zod' -import type { ActionReturnError, ActionErrorCode } from './Types' +import type { ActionReturnError } from './Types' -export function createActionError(errorCode: ActionErrorCode, error?: string | ErrorMessage[]): ActionReturnError { +export function createActionError(errorCode: ErrorCode | AuthStatus, error?: string | ErrorMessage[]): ActionReturnError { + if (errorCode === 'AUTHORIZED' || errorCode === 'AUTHORIZED_NO_USER') { + return { + success: false, + errorCode: 'UNKNOWN ERROR', + httpCode: 500, + error: typeof error === 'string' ? [{ message: error }] : error, + } + } return { success: false, errorCode, + httpCode: errorCodes.find(e => e.name === errorCode)?.httpCode ?? 500, error: typeof error === 'string' ? [{ message: error }] : error, } } @@ -13,6 +23,7 @@ export function createActionError(errorCode: ActionErrorCode, error?: string | E export function createZodActionError(parse: SafeParseError): ActionReturnError { return { success: false, + httpCode: 400, errorCode: 'BAD PARAMETERS', error: parse.error.issues, } diff --git a/src/actions/safeServerCall.ts b/src/actions/safeServerCall.ts index 142a2c6c2..08e91a3a6 100644 --- a/src/actions/safeServerCall.ts +++ b/src/actions/safeServerCall.ts @@ -1,5 +1,5 @@ import { createActionError } from './error' -import { ServerError } from '@/services/error' +import { Smorekopp } from '@/services/error' import type { ActionReturn } from './Types' /** @@ -17,7 +17,7 @@ export async function safeServerCall(call: () => Promise): Promise (selectedTags.length === 1 ? baseUrl : - baseUrl + QueryParams.eventTags.encodeUrl( + `${baseUrl}?${QueryParams.eventTags.encodeUrl( selectedTags.filter(t => t.name !== tag).map(t => t.name) - )) - const addToUrl = (tag: string) => baseUrl + QueryParams.eventTags.encodeUrl( + )}`) + const addToUrl = (tag: string) => `${baseUrl}?${QueryParams.eventTags.encodeUrl( [...selectedTags.map(t => t.name), tag] - ) + )}` return (

Tagger

diff --git a/src/app/_components/NavBar/MobileNavBar.module.scss b/src/app/_components/NavBar/MobileNavBar.module.scss index 297f0d1c5..0671d84d0 100644 --- a/src/app/_components/NavBar/MobileNavBar.module.scss +++ b/src/app/_components/NavBar/MobileNavBar.module.scss @@ -23,7 +23,8 @@ height: 25px; } &.magicHat { - > * { + position: relative; + > .image { filter: invert(1); } } diff --git a/src/app/_components/NavBar/MobileNavBar.tsx b/src/app/_components/NavBar/MobileNavBar.tsx index c5948bcc9..183288f58 100644 --- a/src/app/_components/NavBar/MobileNavBar.tsx +++ b/src/app/_components/NavBar/MobileNavBar.tsx @@ -1,15 +1,16 @@ import getNavItems from './navDef' import styles from './MobileNavBar.module.scss' import Menu from './Menu' +import UserNavigation from './UserNavigation' import SpecialCmsImage from '@/components/Cms/CmsImage/SpecialCmsImage' import EditModeSwitch from '@/components/EditModeSwitch/EditModeSwitch' -import { getUser } from '@/auth/getUser' import Link from 'next/link' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faBars } from '@fortawesome/free-solid-svg-icons' +import type { PropTypes } from './NavBar' -export default async function MobileNavBar() { - const { user } = await getUser() +export default async function MobileNavBar({ profile }: PropTypes) { + const user = profile?.user ?? null const isLoggedIn = user !== null const applicationPeriod = false //TODO const isAdmin = true //TODO @@ -34,9 +35,14 @@ export default async function MobileNavBar() {
- - - + +
diff --git a/src/app/_components/NavBar/NavBar.module.scss b/src/app/_components/NavBar/NavBar.module.scss index 7d16939c5..82f51dada 100644 --- a/src/app/_components/NavBar/NavBar.module.scss +++ b/src/app/_components/NavBar/NavBar.module.scss @@ -30,15 +30,7 @@ } } .magicHat { - a { - width: 100%; - height: 100%; - position: absolute; - top: calc(-1*ohma.$rounding); - left: calc(-1*ohma.$rounding); - width: calc(100% + 2*ohma.$rounding); - height: calc(100% + 2*ohma.$rounding); - } + position: relative; @include ohma.btn(ohma.$colors-primary); } } diff --git a/src/app/_components/NavBar/NavBar.tsx b/src/app/_components/NavBar/NavBar.tsx index c32cdb842..6ac6457ea 100644 --- a/src/app/_components/NavBar/NavBar.tsx +++ b/src/app/_components/NavBar/NavBar.tsx @@ -2,13 +2,18 @@ import Item from './Item' import styles from './NavBar.module.scss' import Menu from './Menu' import getNavItems from './navDef' +import UserNavigation from './UserNavigation' import EditModeSwitch from '@/components/EditModeSwitch/EditModeSwitch' import SpecialCmsImage from '@/components/Cms/CmsImage/SpecialCmsImage' -import { getUser } from '@/auth/getUser' import Link from 'next/link' +import type { Profile } from '@/services/users/Types' -export default async function NavBar() { - const { user } = await getUser() +export type PropTypes = { + profile: Profile | null +} + +export default async function NavBar({ profile }: PropTypes) { + const user = profile?.user ?? null const isLoggedIn = user !== null //temporary @@ -51,9 +56,8 @@ export default async function NavBar() { width={25} height={25} alt="log in button" - > - - + /> + diff --git a/src/app/_components/NavBar/UserNavigation.module.scss b/src/app/_components/NavBar/UserNavigation.module.scss new file mode 100644 index 000000000..ff96e6e3f --- /dev/null +++ b/src/app/_components/NavBar/UserNavigation.module.scss @@ -0,0 +1,84 @@ +@use '@/styles/ohma'; + +.UserNavigation { + @include ohma.screenLg { + position: absolute; + bottom: 0; + right: 0; + transform: translateY(calc(100% + .5em)); + min-width: 300px; + max-height: 90vh; + @include ohma.boxShadow(); + @include ohma.round(); + } + @include ohma.screenMobile { + z-index: -1; + padding: 1em; + padding-top: 3em; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: calc(100vh - 50px); + } + background-color: ohma.$colors-gray-200; + h2 { + color: ohma.$colors-black; + font-size: ohma.$fonts-xl; + } + display: flex; + flex-direction: column; + align-items: center; + .logout { + margin-top: .5em; + width: 100%; + > button { + width: 100%; + margin: 0; + } + } + + .navs { + margin-top: 1em; + width: 100%; + display: flex; + flex-flow: row wrap; + gap: 1em; + > * { + flex: 1 1 calc(50% - 1em); + @include ohma.round(); + background-color: ohma.$colors-secondary; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + color: ohma.$colors-white; + transition: color .6s; + svg { + width: 20px; + height: 20px; + color: ohma.$colors-white; + transition: color .6s; + margin: .3em; + } + &:hover { + color: ohma.$colors-black; + svg { + color: ohma.$colors-black; + } + } + } + } +} + +.hidden { + opacity: 0; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + &:hover { + cursor: pointer; + } +} \ No newline at end of file diff --git a/src/app/_components/NavBar/UserNavigation.tsx b/src/app/_components/NavBar/UserNavigation.tsx new file mode 100644 index 000000000..e16fd922d --- /dev/null +++ b/src/app/_components/NavBar/UserNavigation.tsx @@ -0,0 +1,67 @@ +'use client' +import styles from './UserNavigation.module.scss' +import ProfilePicture from '@/components/User/ProfilePicture' +import BorderButton from '@/UI/BorderButton' +import useClickOutsideRef from '@/hooks/useClickOutsideRef' +import useOnNavigation from '@/hooks/useOnNavigation' +import { faCog, faDotCircle, faMoneyBill, faSignOut, faUser } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import Link from 'next/link' +import { useState } from 'react' +import type { Profile } from '@/services/users/Types' + +type PropTypes = { + profile: Profile | null +} + +/** + * This component either renders an empty link with a href to /login page if there is no profile + * Else it renders a usefull component for a logged in user. + * @param profile - The profile of the user + * @returns + */ +export default function UserNavigation({ profile }: PropTypes) { + const [isMenuOpen, setIsMenuOpen] = useState(false) + const ref = useClickOutsideRef(() => setIsMenuOpen(false)) + useOnNavigation(() => setIsMenuOpen(false)) + + if (!profile || !profile.user) { + return + } + + if (!isMenuOpen) { + return + } { userSelection && } diff --git a/src/app/admin/SlideSidebar.tsx b/src/app/admin/SlideSidebar.tsx index 4fc61f174..6aa2f7e4a 100644 --- a/src/app/admin/SlideSidebar.tsx +++ b/src/app/admin/SlideSidebar.tsx @@ -13,7 +13,8 @@ import { faUserGroup, faArrowLeft, faPaperPlane, - faSchool + faSchool, + faDotCircle } from '@fortawesome/free-solid-svg-icons' import type { IconDefinition } from '@fortawesome/free-solid-svg-icons' import type { ReactNode } from 'react' @@ -152,6 +153,22 @@ const navigations = [ href: '/admin/courses' } ], + }, + { + header: { + icon: faDotCircle, + title: 'Prikker' + }, + links: [ + { + title: 'Prikker', + href: '/admin/dots' + }, + { + title: 'Frysperioder', + href: '/admin/dots-freeze-periods' + }, + ] } ] satisfies { header: { diff --git a/src/app/admin/dots-freeze-periods/page.module.scss b/src/app/admin/dots-freeze-periods/page.module.scss new file mode 100644 index 000000000..e501001f5 --- /dev/null +++ b/src/app/admin/dots-freeze-periods/page.module.scss @@ -0,0 +1,3 @@ +.wrapper { + padding: 1em; +} \ No newline at end of file diff --git a/src/app/admin/dots-freeze-periods/page.tsx b/src/app/admin/dots-freeze-periods/page.tsx new file mode 100644 index 000000000..659a210f0 --- /dev/null +++ b/src/app/admin/dots-freeze-periods/page.tsx @@ -0,0 +1,14 @@ +import styles from './page.module.scss' + +export default function dotsFreezePeriods() { + return ( +
+

Frysperioder for prikker

+ Dette er perioder der prikker ikke fjernes +

+ Merk at opprettelse og endring av frys-perioder IKKE vil påvirke når allerede utdelte prikker + løper ut. Bare prikker som deles ut etter endringen vil påvirkes. +

+
+ ) +} diff --git a/src/app/admin/dots/CreateDotForm.module.scss b/src/app/admin/dots/CreateDotForm.module.scss new file mode 100644 index 000000000..cd73a8fd5 --- /dev/null +++ b/src/app/admin/dots/CreateDotForm.module.scss @@ -0,0 +1,27 @@ +@use '@/styles/ohma'; + +.CreateDotForm { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 2em; + > svg { + color: ohma.$colors-gray-900; + width: 3em; + height: 3em; + } + form, .userSelected { + gap: 1em; + align-items: center; + display: flex; + } + form { + width: auto; + button[type="submit"] { + transform: translateY(10px); + } + } + .openUserList { + @include ohma.btn(ohma.$colors-primary); + } +} \ No newline at end of file diff --git a/src/app/admin/dots/CreateDotForm.tsx b/src/app/admin/dots/CreateDotForm.tsx new file mode 100644 index 000000000..91e514636 --- /dev/null +++ b/src/app/admin/dots/CreateDotForm.tsx @@ -0,0 +1,58 @@ +'use client' +import styles from './CreateDotForm.module.scss' +import { createDotAction } from '@/actions/dots/create' +import Form from '@/app/_components/Form/Form' +import PopUp from '@/app/_components/PopUp/PopUp' +import NumberInput from '@/app/_components/UI/NumberInput' +import TextInput from '@/app/_components/UI/TextInput' +import UserList from '@/app/_components/User/UserList/UserList' +import { useUser } from '@/auth/useUser' +import { PopUpContext } from '@/contexts/PopUp' +import { UserSelectionContext } from '@/contexts/UserSelection' +import { useContext } from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPlus } from '@fortawesome/free-solid-svg-icons' + +export default function CreateDotForm() { + const session = useUser() + const userSelectionContext = useContext(UserSelectionContext) + const popUpContext = useContext(PopUpContext) + if (!session.user) return <> + if (!userSelectionContext) return <> + + userSelectionContext.onSelection(() => { + popUpContext?.remove('selectUserDot') + }) + + return ( +
+ +

+ Gi ny prikk +

+
+

+ { + userSelectionContext.user ? + `${userSelectionContext.user.firstname} ${userSelectionContext.user.lastname}` : + 'ingen bruker valgt' + } +

+ Velg Bruker + }> + + +
+
+ + + + +
+ ) +} diff --git a/src/app/admin/dots/DotList.module.scss b/src/app/admin/dots/DotList.module.scss new file mode 100644 index 000000000..b28ce4e4e --- /dev/null +++ b/src/app/admin/dots/DotList.module.scss @@ -0,0 +1,44 @@ +@use '@/styles/ohma'; + +.DotList { + @include ohma.table(); + margin-top: 0; + .inactive { + text-decoration: line-through; + color: ohma.$colors-red; + } +} + +.selection { + margin-top: 1em; + border-radius: ohma.$rounding ohma.$rounding 0 0; + padding: ohma.$rounding; + background-color: ohma.$colors-gray-300; + display: flex; + flex-flow: row wrap; + align-items: center; + + .openUserList { + @include ohma.btn(ohma.$colors-primary); + font-weight: ohma.$fonts-weight-s; + margin-right: 2em; + } + + .userSelected { + display: flex; + button { + border: none; + background-color: transparent; + svg { + color: ohma.$colors-gray-800; + width: 1.5em; + height: 1.5em; + } + } + } + + .selectActive { + @include ohma.btn(ohma.$colors-primary); + font-weight: ohma.$fonts-weight-s; + } +} \ No newline at end of file diff --git a/src/app/admin/dots/DotList.tsx b/src/app/admin/dots/DotList.tsx new file mode 100644 index 000000000..db9eb1320 --- /dev/null +++ b/src/app/admin/dots/DotList.tsx @@ -0,0 +1,92 @@ +'use client' +import styles from './DotList.module.scss' +import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' +import { DotPagingContext } from '@/contexts/paging/DotPaging' +import { UserSelectionContext } from '@/contexts/UserSelection' +import { QueryParams } from '@/lib/query-params/queryParams' +import PopUp from '@/app/_components/PopUp/PopUp' +import UserList from '@/app/_components/User/UserList/UserList' +import { PopUpContext } from '@/contexts/PopUp' +import Date from '@/app/_components/Date/Date' +import { useRouter } from 'next/navigation' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faX } from '@fortawesome/free-solid-svg-icons' +import Link from 'next/link' +import { useContext } from 'react' + +type PropTypes = { + onlyActive: boolean +} + +export default function DotList({ onlyActive }: PropTypes) { + const userSelection = useContext(UserSelectionContext) + const popUpContext = useContext(PopUpContext) + const { push } = useRouter() + if (!userSelection) return <> + userSelection.onSelection( + user => { + popUpContext?.remove('selectUser') + push(`/admin/dots/?${QueryParams.onlyActive.encodeUrl(onlyActive)}` + + `&${user ? QueryParams.userId.encodeUrl(user.id) : ''}`) + } + ) + + return ( + <> + + { + userSelection.user ? +
+

{userSelection.user.firstname} {userSelection.user.lastname}

+ +
: +
+

Viser prikker for alle brukere

+
+ } + Velg Bruker + }> + + + + {onlyActive ? 'Vis alle prikker' : 'Vis aktive prikker'} + +
+ + + + + + + + + + + + + + + + + } /> + +
GrunnForGitt avUtløpstider
{dotWrapper.reason}{dotWrapper.user.username}{dotWrapper.accuser.username} + { + dotWrapper.dots.map(dot => ( +
+ +
+ )) + } +
+ + ) +} diff --git a/src/app/admin/dots/page.module.scss b/src/app/admin/dots/page.module.scss new file mode 100644 index 000000000..8cde8e26c --- /dev/null +++ b/src/app/admin/dots/page.module.scss @@ -0,0 +1,11 @@ +@use '@/styles/ohma'; + +.wrapper { + padding: 1em; + .createNew { + margin-top: 1em; + background-color: ohma.$colors-gray-500; + @include ohma.round(); + min-height: 100px; + } +} \ No newline at end of file diff --git a/src/app/admin/dots/page.tsx b/src/app/admin/dots/page.tsx new file mode 100644 index 000000000..c8b0ef23a --- /dev/null +++ b/src/app/admin/dots/page.tsx @@ -0,0 +1,54 @@ +import styles from './page.module.scss' +import CreateDotForm from './CreateDotForm' +import DotList from './DotList' +import UserSelectionProvider from '@/contexts/UserSelection' +import UserPagingProvider from '@/contexts/paging/UserPaging' +import PopUpProvider from '@/contexts/PopUp' +import DotPagingProvider from '@/contexts/paging/DotPaging' +import { QueryParams } from '@/lib/query-params/queryParams' +import type { SearchParamsServerSide } from '@/lib/query-params/Types' + +type PropTypes = SearchParamsServerSide + +export default async function Dots({ searchParams }: PropTypes) { + const onlyActive = QueryParams.onlyActive.decode(searchParams) ?? false + const userId = QueryParams.userId.decode(searchParams) + + return ( +
+

Prikker

+
+ + + + + + + +
+
+ + + + + + + + + +
+
+ ) +} diff --git a/src/app/admin/groups/[id]/AddUsersToGroup.tsx b/src/app/admin/groups/[id]/AddUsersToGroup.tsx index 73236d82a..2a42e6ace 100644 --- a/src/app/admin/groups/[id]/AddUsersToGroup.tsx +++ b/src/app/admin/groups/[id]/AddUsersToGroup.tsx @@ -3,7 +3,7 @@ import styles from './AddUsersToGroup.module.scss' import UserList from '@/components/User/UserList/UserList' import Form from '@/components/Form/Form' import { createMembershipsForGroupAction } from '@/actions/groups/memberships/create' -import { UserSelectionContext } from '@/contexts/UserSelection' +import { UsersSelectionContext } from '@/contexts/UsersSelection' import { useContext } from 'react' import { useRouter } from 'next/navigation' import type { PopUpKeyType } from '@/contexts/PopUp' @@ -15,7 +15,7 @@ type PropTypes = { export default function AddUsersToGroup({ groupId, closePopUpOnSuccess }: PropTypes) { const { refresh } = useRouter() - const selectedUsersCtx = useContext(UserSelectionContext) + const selectedUsersCtx = useContext(UsersSelectionContext) const users = selectedUsersCtx?.users || [] return ( diff --git a/src/app/admin/groups/[id]/page.tsx b/src/app/admin/groups/[id]/page.tsx index bdc839890..d4104bdf9 100644 --- a/src/app/admin/groups/[id]/page.tsx +++ b/src/app/admin/groups/[id]/page.tsx @@ -4,7 +4,7 @@ import GroupMembers from './GroupMembers' import UserPagingProvider from '@/contexts/paging/UserPaging' import { CanEasilyManageMembership } from '@/services/groups/memberships/ConfigVars' import PopUp from '@/components/PopUp/PopUp' -import UserSelectionProvider from '@/contexts/UserSelection' +import UsersSelectionProvider from '@/contexts/UsersSelection' import { readGroupExpandedAction } from '@/actions/groups/read' import Link from 'next/link' @@ -60,9 +60,9 @@ export default async function GroupAdmin({ params }: PropTypes) { partOfName: '' }} > - + - + ) : ( diff --git a/src/app/admin/mail/[filter]/[id]/mailList.tsx b/src/app/admin/mail/[filter]/[id]/mailList.tsx index 65a47888c..aab12c448 100644 --- a/src/app/admin/mail/[filter]/[id]/mailList.tsx +++ b/src/app/admin/mail/[filter]/[id]/mailList.tsx @@ -8,6 +8,7 @@ import type { ActionReturn } from '@/actions/Types' import type { MailListTypes, ViaArrayType } from '@/services/mail/Types' import type { Group, MailAddressExternal, MailAlias, MailingList } from '@prisma/client' import type { UserFiltered } from '@/services/users/Types' +import { createActionError } from '@/actions/error' const typeDisplayName: Record = { alias: 'Alias', @@ -49,11 +50,7 @@ export default function MailList({ setItemsState(itemsState.filter(i => i.id !== id)) } - return { - success: false, - errorCode: 'BAD PARAMETERS', - error: [{ message: 'Destory function is not set' }], - } + return createActionError('BAD PARAMETERS', 'No destroy function') } } diff --git a/src/app/api/apiHandler.ts b/src/app/api/apiHandler.ts index b9dd8937e..8d37b6069 100644 --- a/src/app/api/apiHandler.ts +++ b/src/app/api/apiHandler.ts @@ -1,7 +1,7 @@ import 'server-only' import { Session } from '@/auth/Session' import { ServerError, Smorekopp } from '@/services/error' -import type { ActionErrorCode } from '@/actions/Types' +import type { ErrorCode } from '@/services/error' import type { SessionNoUser } from '@/auth/Session' import type { ServiceMethod } from '@/services/ServiceTypes' @@ -110,7 +110,7 @@ export function apiHandler< ) } -function createApiErrorRespone(errorCode: ActionErrorCode, message: string) { +function createApiErrorRespone(errorCode: ErrorCode, message: string) { return new Response(JSON.stringify({ errorCode, message diff --git a/src/app/committees/[shortName]/Nav.module.scss b/src/app/committees/[shortName]/Nav.module.scss index 159804921..4a2126cb8 100644 --- a/src/app/committees/[shortName]/Nav.module.scss +++ b/src/app/committees/[shortName]/Nav.module.scss @@ -8,20 +8,9 @@ } gap: 1em; a { - text-decoration: none; - color: ohma.$colors-black; - background-color: ohma.$colors-primary; + @include ohma.roundAdminSvgBtn(); &.selected { background-color: ohma.$colors-secondary; } - width: 3em; - height: 3em; - border-radius: 50%; - display: grid; - place-items: center; - svg { - width: 2.3em; - height: 2.3em; - } } } \ No newline at end of file diff --git a/src/app/error.module.scss b/src/app/error.module.scss index 0eb9347bd..34c39998f 100644 --- a/src/app/error.module.scss +++ b/src/app/error.module.scss @@ -6,6 +6,7 @@ flex-direction: column; justify-content: center; align-items: center; + min-height: 50vh; .info { display: flex; justify-content: center; diff --git a/src/app/error.tsx b/src/app/error.tsx index 2449ae234..caf8380c1 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -1,17 +1,27 @@ 'use client' - import styles from './error.module.scss' import Button from '@/components/UI/Button' import SpecialCmsImageClient from '@/components/Cms/CmsImage/SpecialCmsImageClient' -export default function ErrorBoundary({ error, reset }: {error: Error, reset: () => void}) { +/** + * note that passing custom error type to next error boundary is not supported + * thus the error is encoded in a normal Error object + * Look at redirectToErrorPage to how it is implemented. +*/ +export default function ErrorBoundary({ error, reset }: {error: unknown, reset: () => void}) { return (
-

500 - {error.message}

+ { + error instanceof Error ? ( +

{error.message}

+ ) : ( +

Ukjent feil

+ ) + }
diff --git a/src/app/events/EventsLandingLayout.tsx b/src/app/events/EventsLandingLayout.tsx index b33b3fa2b..42d99d493 100644 --- a/src/app/events/EventsLandingLayout.tsx +++ b/src/app/events/EventsLandingLayout.tsx @@ -16,6 +16,7 @@ type PropTypes = { title: string, children: ReactNode selectedTags?: EventTagT[] + page: 'EVENT' | 'EVENT_ARCHIVE' } export default function EventsLandingLayout({ @@ -23,8 +24,10 @@ export default function EventsLandingLayout({ headerLinks, title, selectedTags, + page, children }: PropTypes) { + const baseUrl = page === 'EVENT' ? '/events' : '/events/archive' return (
@@ -32,8 +35,8 @@ export default function EventsLandingLayout({

{title}

{ selectedTags?.map(tag => - t.name !== tag.name).map(t => t.name) )}` }> diff --git a/src/app/events/[order]/[name]/page.tsx b/src/app/events/[order]/[name]/page.tsx index 7808f69d8..777e42a4a 100644 --- a/src/app/events/[order]/[name]/page.tsx +++ b/src/app/events/[order]/[name]/page.tsx @@ -43,7 +43,7 @@ export default async function Event({ params }: PropTypes) {
    {event.tags.map(tag => (
  • - +
  • diff --git a/src/app/events/archive/page.tsx b/src/app/events/archive/page.tsx index 10673b369..d83faaf94 100644 --- a/src/app/events/archive/page.tsx +++ b/src/app/events/archive/page.tsx @@ -26,9 +26,9 @@ export default async function EventArchive({ const canDestroy = DestroyEventTagAuther.dynamicFields({}).auth(session) return ( - @@ -43,7 +47,7 @@ export default async function RootLayout({ children }: PropTypes) {
    - +
    {children} @@ -52,7 +56,7 @@ export default async function RootLayout({ children }: PropTypes) {
    - +
    diff --git a/src/app/not-found.module.scss b/src/app/not-found.module.scss index 661ebacfb..0030e5dcf 100644 --- a/src/app/not-found.module.scss +++ b/src/app/not-found.module.scss @@ -6,6 +6,7 @@ flex-direction: column; justify-content: center; align-items: center; + min-height: 50vh; .info { display: flex; justify-content: center; diff --git a/src/app/redirectToErrorPage.ts b/src/app/redirectToErrorPage.ts new file mode 100644 index 000000000..e587b8d90 --- /dev/null +++ b/src/app/redirectToErrorPage.ts @@ -0,0 +1,39 @@ +import { errorCodes } from '@/services/error' +import type { ActionReturn } from '@/actions/Types' +import type { ErrorCode } from '@/services/error' + +/** + * Function that when thrown ON RENDER will redirect to the error page (error.tsx) + * The error must be encoded in regular Error object as the custom error type is not supported by Next.js + * (next.js will convert custom error types to regular Error objects) + * Thus the error must be encoded in a regular Error object in one string + * @param + */ +export function redirectToErrorPage(code: ErrorCode, message?: string | undefined): never { + const defaultMessage = errorCodes.find((error) => error.name === code)?.defaultMessage ?? 'Ukjent feil' + const httpStatusCode = errorCodes.find((error) => error.name === code)?.httpCode ?? 500 + throw new Error(`${httpStatusCode} - ${message ? message : defaultMessage} (${code})`) +} + +/** + * This function is used on server render to throw user to error page if the action return is not successful + * If the action return is successful, the data is returned + * @param actionReturn + * @returns data if the action return is successful + * @throws Error if the action return is not successful (ends up on error page - error.tsx) + */ +export function unwrapActionReturn< + Data, +>(actionReturn: ActionReturn): Data +export function unwrapActionReturn< + Data, +>(actionReturn: ActionReturn): Data | undefined +export function unwrapActionReturn< + Data, + const DataGuarantee extends boolean, +>(actionReturn: ActionReturn): Data | undefined { + if (!actionReturn.success) { + redirectToErrorPage(actionReturn.errorCode, actionReturn.error?.length ? actionReturn.error[0].message : undefined) + } + return actionReturn.data +} diff --git a/src/app/users/[username]/(user-admin)/Nav.module.scss b/src/app/users/[username]/(user-admin)/Nav.module.scss new file mode 100644 index 000000000..b3d2ef35f --- /dev/null +++ b/src/app/users/[username]/(user-admin)/Nav.module.scss @@ -0,0 +1,20 @@ +@use '@/styles/ohma'; + +.Nav { + position: fixed; + top: calc(100px + 10vh); + right: calc(10vw + 10px); + transform: translateX(calc(100% + 1em)); + display: flex; + flex-direction: column; + gap: 1em; + > a { + @include ohma.roundAdminSvgBtn; + &.selected { + background-color: ohma.$colors-secondary; + &:hover svg { + color: initial; + } + } + } +} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/Nav.tsx b/src/app/users/[username]/(user-admin)/Nav.tsx new file mode 100644 index 000000000..89f071ec3 --- /dev/null +++ b/src/app/users/[username]/(user-admin)/Nav.tsx @@ -0,0 +1,43 @@ +'use client' +import styles from './Nav.module.scss' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import Link from 'next/link' +import { faCircleDot, faCog, faKey, faPaperPlane } from '@fortawesome/free-solid-svg-icons' +import { usePathname } from 'next/navigation' + +type PropTypes = { + username: string +} + +export default function Nav({ username }: PropTypes) { + const pathname = usePathname() + const page = pathname.split('/').pop() + return ( + + ) +} diff --git a/src/app/users/[username]/(user-admin)/dots/page.module.scss b/src/app/users/[username]/(user-admin)/dots/page.module.scss new file mode 100644 index 000000000..076f1106d --- /dev/null +++ b/src/app/users/[username]/(user-admin)/dots/page.module.scss @@ -0,0 +1,9 @@ +@use '@/styles/ohma'; + +.dotList { + @include ohma.table(); + .inactive { + text-decoration: line-through; + color: ohma.$colors-red; + } +} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/dots/page.tsx b/src/app/users/[username]/(user-admin)/dots/page.tsx new file mode 100644 index 000000000..fb8ee9b28 --- /dev/null +++ b/src/app/users/[username]/(user-admin)/dots/page.tsx @@ -0,0 +1,48 @@ +import styles from './page.module.scss' +import { readDotWrapperForUser } from '@/actions/dots/read' +import { unwrapActionReturn } from '@/app/redirectToErrorPage' +import { getProfileForAdmin, type PropTypes } from '@/app/users/[username]/(user-admin)/getProfileForAdmin' +import Date from '@/components/Date/Date' + +export default async function UserDotAdmin(params: PropTypes) { + const { profile } = await getProfileForAdmin(params, 'dots') + const dotWrappers = unwrapActionReturn( + await readDotWrapperForUser.bind(null, { userId: profile.user.id, onlyActive: false })() + ) + + return ( +
    +

    Prikker

    + + + + + + + + + + + { + dotWrappers.map(dotWrapper => ( + + + + + + + )) + } + +
    GrunnForGitt avUtløpstider
    {dotWrapper.reason}{dotWrapper.user.username}{dotWrapper.accuser.username} + { + dotWrapper.dots.map(dot => ( +
    + +
    + )) + } +
    +
    + ) +} diff --git a/src/app/users/[username]/(user-admin)/getProfileForAdmin.ts b/src/app/users/[username]/(user-admin)/getProfileForAdmin.ts new file mode 100644 index 000000000..a7614b79f --- /dev/null +++ b/src/app/users/[username]/(user-admin)/getProfileForAdmin.ts @@ -0,0 +1,33 @@ +import { readUserProfileAction } from '@/actions/users/read' +import { Session } from '@/auth/Session' +import { UserProfileUpdateAuther } from '@/services/users/Authers' +import { notFound, redirect } from 'next/navigation' + +export type PropTypes = { + params: { + username: string + } +} + +/** + * Wrapper used on all user-admin pages to auth the route and get the profile of the user. + * @param params - The username of the user to get the profile of. if 'me' is passed, + * the profile of the current user is returned. + * @param adminPage - The page that the user is being seen on. Used to rediret if the user is not authorized currently. + * @returns - The profile being seen and the session of the current user. +*/ +export async function getProfileForAdmin({ params }: PropTypes, adminPage: string) { + const session = await Session.fromNextAuth() + if (params.username === 'me') { + if (!session.user) return notFound() + redirect(`/users/${session.user.username}/${adminPage}`) //This throws. + } + UserProfileUpdateAuther + .dynamicFields({ username: params.username }) + .auth(session) + .requireAuthorized({ returnUrlIfFail: `/users/${params.username}/${adminPage}` }) + const profileRes = await readUserProfileAction(params.username) + if (!profileRes.success) return notFound() + const profile = profileRes.data + return { profile, session } +} diff --git a/src/app/users/[username]/(user-admin)/layout.module.scss b/src/app/users/[username]/(user-admin)/layout.module.scss new file mode 100644 index 000000000..5448a0132 --- /dev/null +++ b/src/app/users/[username]/(user-admin)/layout.module.scss @@ -0,0 +1,15 @@ +@use '@/styles/ohma'; + +.userAdminLayout { + position: relative; + main { + margin-top: 1em; + } +} + +.toProfile { + position: fixed; + top: 100px; + left: 50px; + @include ohma.borderBtn(ohma.$colors-secondary); +} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/layout.tsx b/src/app/users/[username]/(user-admin)/layout.tsx new file mode 100644 index 000000000..7484bb62d --- /dev/null +++ b/src/app/users/[username]/(user-admin)/layout.tsx @@ -0,0 +1,35 @@ +import styles from './layout.module.scss' +import Nav from './Nav' +import { Session } from '@/auth/Session' +import { readUserProfileAction } from '@/actions/users/read' +import { unwrapActionReturn } from '@/app/redirectToErrorPage' +import PageWrapper from '@/app/_components/PageWrapper/PageWrapper' +import { notFound } from 'next/navigation' +import Link from 'next/link' +import type { ReactNode } from 'react' +import type { PropTypes } from './getProfileForAdmin' + +export default async function UserAdmin({ children, params }: PropTypes & { children: ReactNode }) { + const session = await Session.fromNextAuth() + let username = params.username + if (username === 'me') { + if (!session.user) return notFound() + username = session.user.username + } + const { user } = unwrapActionReturn(await readUserProfileAction(username)) + return ( + + + Til Profilsiden + +
    + Bruker Id: {user.id}
    + Brukernavn: {user.username} +
    + {children} +
    +
    +
    + ) +} diff --git a/src/app/users/[username]/notifications/Types.ts b/src/app/users/[username]/(user-admin)/notifications/Types.ts similarity index 100% rename from src/app/users/[username]/notifications/Types.ts rename to src/app/users/[username]/(user-admin)/notifications/Types.ts diff --git a/src/app/users/[username]/notifications/notificationSettings.module.scss b/src/app/users/[username]/(user-admin)/notifications/notificationSettings.module.scss similarity index 100% rename from src/app/users/[username]/notifications/notificationSettings.module.scss rename to src/app/users/[username]/(user-admin)/notifications/notificationSettings.module.scss diff --git a/src/app/users/[username]/notifications/notificationSettings.tsx b/src/app/users/[username]/(user-admin)/notifications/notificationSettings.tsx similarity index 98% rename from src/app/users/[username]/notifications/notificationSettings.tsx rename to src/app/users/[username]/(user-admin)/notifications/notificationSettings.tsx index 0930784d6..a567cb87b 100644 --- a/src/app/users/[username]/notifications/notificationSettings.tsx +++ b/src/app/users/[username]/(user-admin)/notifications/notificationSettings.tsx @@ -7,10 +7,10 @@ import { notificationMethodsDisplayMap } from '@/services/notifications/ConfigVa import { notificationMethods } from '@/services/notifications/Types' import SubmitButton from '@/components/UI/SubmitButton' import { updateSubscriptionsAction } from '@/actions/notifications/subscription/update' -import { useUser } from '@/auth/useUser' import { SUCCESS_FEEDBACK_TIME } from '@/components/Form/ConfigVars' import { v4 as uuid } from 'uuid' import { useState } from 'react' +import type { UserFiltered } from '@/services/users/Types' import type { MinimizedSubscription, Subscription } from '@/services/notifications/subscription/Types' import type { NotificationBranch } from './Types' import type { ErrorMessage } from '@/services/error' @@ -131,13 +131,17 @@ function prepareDataForDelivery(tree: NotificationBranch) { return ret } +type PropTypes = { + channels: ExpandedNotificationChannel[], + subscriptions: Subscription[], + user: UserFiltered +} + export default function NotificationSettings({ channels, subscriptions, -}: { - channels: ExpandedNotificationChannel[], - subscriptions: Subscription[], -}) { + user +}: PropTypes) { const [channelTree, setChannelTree] = useState( generateChannelTree(channels, subscriptions) ) @@ -153,9 +157,6 @@ export default function NotificationSettings({ success: false, }) - const { user } = useUser() - - function handleChange(branchId: number, method: NotificationMethodGeneral) { const branch = findBranchInTree(channelTree, branchId) if (!branch) return diff --git a/src/app/users/[username]/notifications/page.tsx b/src/app/users/[username]/(user-admin)/notifications/page.tsx similarity index 55% rename from src/app/users/[username]/notifications/page.tsx rename to src/app/users/[username]/(user-admin)/notifications/page.tsx index 5daea4d62..792660c28 100644 --- a/src/app/users/[username]/notifications/page.tsx +++ b/src/app/users/[username]/(user-admin)/notifications/page.tsx @@ -2,9 +2,10 @@ import NotificationSettings from './notificationSettings' import { readNotificationChannelsAction } from '@/actions/notifications/channel/read' import { readSubscriptionsAction } from '@/actions/notifications/subscription/read' -import PageWrapper from '@/components/PageWrapper/PageWrapper' +import { getProfileForAdmin, type PropTypes } from '@/app/users/[username]/(user-admin)/getProfileForAdmin' -export default async function Notififcations() { +export default async function Notififcations(props: PropTypes) { + const { profile } = await getProfileForAdmin(props, 'notifications') // TODO: Make mobile friendly const [channels, subscriptions] = await Promise.all([ @@ -16,9 +17,10 @@ export default async function Notififcations() { throw new Error('Failed to load channels or subscriptions') } - return - - + return ( +
    +

    Notifikasjoner

    + +
    + ) } diff --git a/src/app/users/[username]/notifications/subscriptionItem.module.scss b/src/app/users/[username]/(user-admin)/notifications/subscriptionItem.module.scss similarity index 100% rename from src/app/users/[username]/notifications/subscriptionItem.module.scss rename to src/app/users/[username]/(user-admin)/notifications/subscriptionItem.module.scss diff --git a/src/app/users/[username]/notifications/subscriptionItem.tsx b/src/app/users/[username]/(user-admin)/notifications/subscriptionItem.tsx similarity index 100% rename from src/app/users/[username]/notifications/subscriptionItem.tsx rename to src/app/users/[username]/(user-admin)/notifications/subscriptionItem.tsx diff --git a/src/app/users/[username]/settings/page.module.scss b/src/app/users/[username]/(user-admin)/permissions/page.module.scss similarity index 50% rename from src/app/users/[username]/settings/page.module.scss rename to src/app/users/[username]/(user-admin)/permissions/page.module.scss index 48f333a66..45fdc8ad7 100644 --- a/src/app/users/[username]/settings/page.module.scss +++ b/src/app/users/[username]/(user-admin)/permissions/page.module.scss @@ -1,11 +1,7 @@ @use '@/styles/ohma'; .wrapper { - padding: ohma.$minimalPagePadding; -} - -.userLinks { - display: inline; + } .permission { diff --git a/src/app/users/[username]/(user-admin)/permissions/page.tsx b/src/app/users/[username]/(user-admin)/permissions/page.tsx new file mode 100644 index 000000000..74cfe1dab --- /dev/null +++ b/src/app/users/[username]/(user-admin)/permissions/page.tsx @@ -0,0 +1,23 @@ +import styles from './page.module.scss' +import Permission from '@/components/Permission/Permission' +import { getProfileForAdmin, type PropTypes } from '@/app/users/[username]/(user-admin)/getProfileForAdmin' +import { v4 as uuid } from 'uuid' + +export default async function UserSettings(props: PropTypes) { + const { profile } = await getProfileForAdmin(props, 'permissions') + + return ( +
    +

    Tillganger:

    +
      + {profile.permissions.map(permission => + + )} +
    +

    Grupper:

    +
      + {profile.memberships.map(membership =>
    • {membership.groupId}
    • )} +
    +
    + ) +} diff --git a/src/app/users/[username]/(user-admin)/settings/page.tsx b/src/app/users/[username]/(user-admin)/settings/page.tsx new file mode 100644 index 000000000..0843d3b47 --- /dev/null +++ b/src/app/users/[username]/(user-admin)/settings/page.tsx @@ -0,0 +1,13 @@ +import { getProfileForAdmin, type PropTypes } from '@/app/users/[username]/(user-admin)/getProfileForAdmin' +import Image from '@/components/Image/Image' + +export default async function UserSettings(props: PropTypes) { + const { profile } = await getProfileForAdmin(props, 'settings') + + return ( +
    +

    Generelle Instillinger

    + +
    + ) +} diff --git a/src/app/users/[username]/page.module.scss b/src/app/users/[username]/page.module.scss index 21dd18d62..69bae29a8 100644 --- a/src/app/users/[username]/page.module.scss +++ b/src/app/users/[username]/page.module.scss @@ -93,19 +93,6 @@ gap: 0.5em; - .imageWrapper { - display: flex; - justify-content: center; - - .profilePicture { - border-radius: 50%; - border: 3px solid ohma.$colors-white; - background-color: ohma.$colors-white; - aspect-ratio: 1; - object-fit: cover; - } - } - .header { display: flex; flex-direction: column; diff --git a/src/app/users/[username]/page.tsx b/src/app/users/[username]/page.tsx index 1ed93a092..22626ba07 100644 --- a/src/app/users/[username]/page.tsx +++ b/src/app/users/[username]/page.tsx @@ -1,6 +1,4 @@ import styles from './page.module.scss' -import { getUser } from '@/auth/getUser' -import Image from '@/components/Image/Image' import { readSpecialImage } from '@/services/images/read' import BorderButton from '@/components/UI/BorderButton' import { readCommitteesFromIds } from '@/services/groups/committees/read' @@ -8,12 +6,14 @@ import { readUserProfileAction } from '@/actions/users/read' import { sexConfig } from '@/services/users/ConfigVars' import OmegaId from '@/components/OmegaId/identification/OmegaId' import PopUp from '@/components/PopUp/PopUp' +import { Session } from '@/auth/Session' +import { UserProfileUpdateAuther } from '@/services/users/Authers' +import ProfilePicture from '@/app/_components/User/ProfilePicture' import Link from 'next/link' -import { notFound } from 'next/navigation' +import { notFound, redirect } from 'next/navigation' import { v4 as uuid } from 'uuid' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faQrcode } from '@fortawesome/free-solid-svg-icons' -import type { UserFiltered } from '@/services/users/Types' type PropTypes = { params: { @@ -21,29 +21,15 @@ type PropTypes = { }, } -/** - * Function to get the correct profile to view on profile pages - * @param user - The user logged on - * @param username - The username of the user to get the profile of - * @returns - The profile of the user to view, me if the user is the same as the logged on user - */ -export async function getProfile(user: UserFiltered | null, paramUsername: string) { - const me = user && (paramUsername === 'me' || paramUsername === user.username) - - const profileRes = await readUserProfileAction(me ? user.username : paramUsername) - - if (!profileRes.success) notFound() - - return { profile: profileRes.data, me } -} - export default async function User({ params }: PropTypes) { - const { user, permissions } = await getUser({ - shouldRedirect: true, - returnUrl: `/users/${params.username}`, - userRequired: params.username === 'me' - }) - const { profile, me } = await getProfile(user, params.username) + const session = await Session.fromNextAuth() + if (params.username === 'me') { + if (!session.user) return notFound() + redirect(`/users/${session.user.username}`) //This throws. + } + const profileRes = await readUserProfileAction(params.username) + if (!profileRes.success) return notFound() + const profile = profileRes.data // REFACTOR THIS PART, THE ORDER IS BASED ON ORDER OF MEMBERSHIP NOT STUDYPROGRAMME ALSO I THINK const groupIds = profile.memberships.map(group => group.groupId) @@ -57,7 +43,9 @@ export default async function User({ params }: PropTypes) { const profileImage = profile.user.image ? profile.user.image : await readSpecialImage('DEFAULT_PROFILE_IMAGE') - const canAdministrate = me || permissions.includes('USERS_UPDATE') + const { authorized: canAdministrate } = UserProfileUpdateAuther.dynamicFields( + { username: profile.user.username } + ).auth(session) return (
    @@ -65,9 +53,7 @@ export default async function User({ params }: PropTypes) {
    {/* TODO change style based on flair */}
    -
    - -
    +

    {`${profile.user.firstname} ${profile.user.lastname}`}

    @@ -108,11 +94,14 @@ export default async function User({ params }: PropTypes) {

    Instillinger

    } - {me && - -

    Logg ut

    -
    - } + {profile.user.id === session?.user?.id && ( + + +

    Logg ut

    +
    + + ) + }
    diff --git a/src/app/users/[username]/permissions/page.tsx b/src/app/users/[username]/permissions/page.tsx deleted file mode 100644 index e4602eb36..000000000 --- a/src/app/users/[username]/permissions/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { getUser } from '@/auth/getUser' -import { getProfile } from '@/app/users/[username]/page' - -type PropTypes = { - params: { - username: string - } -} - -export default async function UserSettings({ params }: PropTypes) { - const { user } = await getUser({ - shouldRedirect: true, - returnUrl: `/users/${params.username}/settings`, - userRequired: params.username === 'me' - }) - - const { profile } = await getProfile(user, params.username) - - return ( -
    -

    Settings for {profile.user.firstname}

    -

    Here you can change your settings

    -
    - ) -} diff --git a/src/app/users/[username]/settings/page.tsx b/src/app/users/[username]/settings/page.tsx deleted file mode 100644 index eba258c0a..000000000 --- a/src/app/users/[username]/settings/page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import styles from './page.module.scss' -import { getProfile } from '@/app/users/[username]/page' -import { getUser } from '@/auth/getUser' -import Permission from '@/components/Permission/Permission' -import { v4 as uuid } from 'uuid' -import { notFound } from 'next/navigation' -import Link from 'next/link' - -type PropTypes = { - params: { - username: string - }, -} - -export default async function UserSettings({ params }: PropTypes) { - const { user, permissions } = await getUser({ - shouldRedirect: true, - returnUrl: `/users/${params.username}/settings`, - userRequired: params.username === 'me' - }) - - const { profile, me } = await getProfile(user, params.username) - - if (!me && !permissions.includes('USERS_UPDATE')) return notFound() - - return ( -
    - Tilbake -

    {profile.user.firstname} {profile.user.lastname}

    -
    - Varslinger -
    -

    {`Bruker-ID: ${profile.user.id}`}

    -

    Tillganger:

    -
      - {profile.permissions.map(permission => - - )} -
    -

    Grupper:

    -
      - {profile.memberships.map(membership =>
    • {membership.groupId}
    • )} -
    -
    - ) -} diff --git a/src/auth/auther/AuthResult.ts b/src/auth/auther/AuthResult.ts index 1b7c0577c..bec0811f6 100644 --- a/src/auth/auther/AuthResult.ts +++ b/src/auth/auther/AuthResult.ts @@ -1,3 +1,5 @@ +import { redirectToErrorPage } from '@/app/redirectToErrorPage' +import { redirect } from 'next/navigation' import type { SessionType, UserGuaranteeOption } from '@/auth/Session' export type AuthStatus = 'AUTHORIZED' | 'UNAUTHORIZED' | 'AUTHORIZED_NO_USER' | 'UNAUTHENTICATED' @@ -22,5 +24,13 @@ export class AuthResult { + if (!this.authorized) { + if (this.session.user) redirectToErrorPage('UNAUTHORIZED') + redirect(`/login?callbackUrl=${encodeURI(returnUrlIfFail)}`) + } + return new AuthResult(this.session, true) + } } diff --git a/src/auth/auther/RequirePermissionAndUserId.ts b/src/auth/auther/RequirePermissionAndUserId.ts new file mode 100644 index 000000000..6ba045130 --- /dev/null +++ b/src/auth/auther/RequirePermissionAndUserId.ts @@ -0,0 +1,25 @@ +import { AutherFactory } from './Auther' +import type { Permission } from '@prisma/client' + +export const RequirePermissionAndUserId = AutherFactory< + { permission: Permission }, + { userId: number }, + 'USER_REQUIERED_FOR_AUTHORIZED' +>(({ session, staticFields, dynamicFields }) => { + if (!session.user) { + return { + success: false, + session + } + } + if (session.user.id !== dynamicFields.userId) { + return { + success: false, + session + } + } + return { + success: session.permissions.includes(staticFields.permission), + session + } +}) diff --git a/src/auth/auther/RequireUserIdOrPermission.ts b/src/auth/auther/RequireUserIdOrPermission.ts new file mode 100644 index 000000000..ed5053258 --- /dev/null +++ b/src/auth/auther/RequireUserIdOrPermission.ts @@ -0,0 +1,19 @@ +import { AutherFactory } from './Auther' +import type { Permission } from '@prisma/client' + +export const RequireUserIdOrPermission = AutherFactory< + { permission: Permission }, + { userId: number }, + 'USER_NOT_REQUIERED_FOR_AUTHORIZED' +>(({ session, staticFields, dynamicFields }) => { + if (session.permissions.includes(staticFields.permission)) { + return { + success: true, + session + } + } + return { + success: session.user !== null && session.user.id === dynamicFields.userId, + session + } +}) diff --git a/src/auth/auther/RequireUsernameOrPermission.ts b/src/auth/auther/RequireUsernameOrPermission.ts index a3ee5ce28..d7ba6164c 100644 --- a/src/auth/auther/RequireUsernameOrPermission.ts +++ b/src/auth/auther/RequireUsernameOrPermission.ts @@ -13,7 +13,7 @@ export const RequireUsernameOrPermission = AutherFactory< } } return { - success: session.user?.username === dynamicFields.username, + success: session.user !== null && session.user.username === dynamicFields.username, session } }) diff --git a/src/contexts/UserSelection.tsx b/src/contexts/UserSelection.tsx index e3b436867..340224d7f 100644 --- a/src/contexts/UserSelection.tsx +++ b/src/contexts/UserSelection.tsx @@ -1,11 +1,11 @@ 'use client' - -import { createContext, useState } from 'react' +import { createContext, useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import type { UserFiltered } from '@/services/users/Types' type PropTypes = { children: ReactNode + initialUser?: UserFiltered | null } /** @@ -13,33 +13,27 @@ type PropTypes = { * If UserList is rendered inside IserSelectionProvider, it will display a checkbox next to each user. */ export const UserSelectionContext = createContext<{ - users: UserFiltered[] - addUser: (user: UserFiltered) => void - removeUser: (user: UserFiltered) => void - toggle: (user: UserFiltered) => void - includes: (user: UserFiltered) => boolean + user: UserFiltered | null + setUser: (user: UserFiltered | null) => void + onSelection: (handler: (user: UserFiltered | null) => void) => void } | null>(null) -export default function UserSelectionProvider({ children }: PropTypes) { - const [users, setUsers] = useState([]) - - const addUser = (user: UserFiltered) => { - setUsers([...users, user]) - } - const removeUser = (user: UserFiltered) => { - setUsers(users.filter(u => u !== user)) - } - const toggle = (user: UserFiltered) => { - if (users.includes(user)) { - removeUser(user) - } else { - addUser(user) - } - } +type Handler = (user: UserFiltered | null) => void - const includes = (user: UserFiltered) => users.includes(user) +export default function UserSelectionProvider({ children, initialUser }: PropTypes) { + const [user, setUser] = useState(initialUser ? initialUser : null) + const onSelection = useRef(() => {}) + useEffect(() => { + onSelection.current(user) + }, [user]) - return + return { + onSelection.current = handler + } + }}> {children} } diff --git a/src/contexts/UsersSelection.tsx b/src/contexts/UsersSelection.tsx new file mode 100644 index 000000000..6b72eda46 --- /dev/null +++ b/src/contexts/UsersSelection.tsx @@ -0,0 +1,45 @@ +'use client' + +import { createContext, useState } from 'react' +import type { ReactNode } from 'react' +import type { UserFiltered } from '@/services/users/Types' + +type PropTypes = { + children: ReactNode +} + +/** + * Context designed to be used with UserPagingContext and UserList. + * If UserList is rendered inside IserSelectionProvider, it will display a checkbox next to each user. + */ +export const UsersSelectionContext = createContext<{ + users: UserFiltered[] + addUser: (user: UserFiltered) => void + removeUser: (user: UserFiltered) => void + toggle: (user: UserFiltered) => void + includes: (user: UserFiltered) => boolean + } | null>(null) + +export default function UsesrSelectionProvider({ children }: PropTypes) { + const [users, setUsers] = useState([]) + + const addUser = (user: UserFiltered) => { + setUsers([...users, user]) + } + const removeUser = (user: UserFiltered) => { + setUsers(users.filter(u => u !== user)) + } + const toggle = (user: UserFiltered) => { + if (users.includes(user)) { + removeUser(user) + } else { + addUser(user) + } + } + + const includes = (user: UserFiltered) => users.includes(user) + + return + {children} + +} diff --git a/src/contexts/paging/DotPaging.tsx b/src/contexts/paging/DotPaging.tsx new file mode 100644 index 000000000..efc380f5b --- /dev/null +++ b/src/contexts/paging/DotPaging.tsx @@ -0,0 +1,24 @@ +'use client' +import generatePagingProvider, { generatePagingContext } from './PagingGenerator' +import { readDotPage } from '@/actions/dots/read' +import type { ReadPageInput } from '@/services/paging/Types' +import type { DotDetails, DotCursor, DotWrapperWithDots } from '@/services/dots/Types' + +export type PageSizeDots = 30 +const fetcher = async (x: ReadPageInput) => { + const ret = await readDotPage.bind(null, { paging: x })() + return ret +} + +export const DotPagingContext = generatePagingContext< + DotWrapperWithDots, + DotCursor, + PageSizeDots, + DotDetails +>() +const DotPagingProvider = generatePagingProvider({ + Context: DotPagingContext, + fetcher, + getCursorAfterFetch: data => (data.length ? { id: data[data.length - 1].id } : null), +}) +export default DotPagingProvider diff --git a/src/dates/displayDate.ts b/src/dates/displayDate.ts index d8ce9aa8d..a31eb98e8 100644 --- a/src/dates/displayDate.ts +++ b/src/dates/displayDate.ts @@ -4,7 +4,10 @@ * @returns */ export function displayDate(date: Date): string { - return date.toLocaleString('nb-NO', { + const offset = date.getTimezoneOffset() + const clientOffset = new Date().getTimezoneOffset() + const diff = offset - clientOffset + return new Date(date.getTime() + diff * 60 * 1000).toLocaleString('nb-NO', { year: 'numeric', month: '2-digit', day: '2-digit', diff --git a/src/hooks/useActionCall.ts b/src/hooks/useActionCall.ts index abe0359a0..35669685a 100644 --- a/src/hooks/useActionCall.ts +++ b/src/hooks/useActionCall.ts @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect } from 'react' import type { ActionReturn, ActionReturnError } from '@/actions/Types' +import { createActionError } from '@/actions/error' /** * You sometimes want to call a server action that reads from the client. This hook helps with that. @@ -32,7 +33,7 @@ export default function useActionCall< setRes({ data: null, error: result }) } }).catch(() => { - setRes({ data: null, error: { success: false, errorCode: 'UNKNOWN ERROR' } }) + setRes({ data: null, error: createActionError('UNKNOWN ERROR', 'An unknown error occured') }) }) }, [action]) diff --git a/src/jwt/parseJWTClient.ts b/src/jwt/parseJWTClient.ts index ef8da0efa..fa5865c40 100644 --- a/src/jwt/parseJWTClient.ts +++ b/src/jwt/parseJWTClient.ts @@ -5,6 +5,7 @@ import { JWT_ISSUER } from '@/auth/ConfigVars' import type { OmegaJWTAudience } from '@/auth/Types' import type { ActionReturn } from '@/actions/Types' import type { OmegaId } from '@/services/omegaid/Types' +import { createActionError } from '@/actions/error' /** * Parses a JSON Web Token (JWT) and verifies its signature using the provided public key. @@ -19,13 +20,7 @@ export async function parseJWT(token: string, publicKey: string, timeOffset: num // TODO: This only works in safari and firefox :/// function invalidJWT(message?: string): ActionReturn { - return { - success: false, - errorCode: 'JWT INVALID', - error: message ? [{ - message - }] : [] - } + return createActionError('JWT INVALID', message || 'Ugyldig QR kode') } if (timeOffset < 0) { diff --git a/src/lib/query-params/QueryParam.ts b/src/lib/query-params/QueryParam.ts index a42c2f137..dd0d2dec2 100644 --- a/src/lib/query-params/QueryParam.ts +++ b/src/lib/query-params/QueryParam.ts @@ -9,7 +9,7 @@ export abstract class QueryParam { abstract encode(value: Type): string public encodeUrl(value: Type): string { - return `?${this.name}=${encodeURIComponent(this.encode(value))}` + return `${this.name}=${encodeURIComponent(this.encode(value))}` } abstract decodeValue(value: string | string[] | undefined): Type | null public decode(searchParams: SearchParamsServerSide['searchParams']): Type | null { @@ -52,3 +52,29 @@ export class StringArrayQueryParam extends QueryParam { return null } } + +export class BooleanQueryParam extends QueryParam { + encode(value: boolean): string { + return value ? 'true' : 'false' + } + + decodeValue(value: string | string[] | undefined): boolean | null { + if (typeof value === 'string') { + return value === 'true' + } + return null + } +} + +export class NumberQueryParam extends QueryParam { + encode(value: number): string { + return value.toString() + } + + decodeValue(value: string | string[] | undefined): number | null { + if (typeof value === 'string') { + return parseInt(value, 10) + } + return null + } +} diff --git a/src/lib/query-params/queryParams.ts b/src/lib/query-params/queryParams.ts index f0c5af943..b9b2a9e15 100644 --- a/src/lib/query-params/queryParams.ts +++ b/src/lib/query-params/queryParams.ts @@ -1,7 +1,9 @@ -import { StringArrayQueryParam } from './QueryParam' +import { BooleanQueryParam, NumberQueryParam, StringArrayQueryParam } from './QueryParam' import type { QueryParam } from './QueryParam' export const QueryParams = { eventTags: new StringArrayQueryParam('event-tags'), -} as const satisfies Record> + onlyActive: new BooleanQueryParam('only-active'), + userId: new NumberQueryParam('user-id'), +} as const satisfies Record> export type QueryParamNames = typeof QueryParams[keyof typeof QueryParams]['name'] diff --git a/src/prisma/schema/dots.prisma b/src/prisma/schema/dots.prisma new file mode 100644 index 000000000..ca2f0b69e --- /dev/null +++ b/src/prisma/schema/dots.prisma @@ -0,0 +1,31 @@ +model DotWrapper { + id Int @id @default(autoincrement()) + user User @relation(name: "dot_user", fields: [userId], references: [id]) + userId Int + reason String + accuser User @relation(name: "dot_accuser", fields: [accuserId], references: [id]) + accuserId Int + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + dots Dot[] +} + +model Dot { + id Int @id @default(autoincrement()) + wrapper DotWrapper @relation(fields: [dotWrapperId], references: [id]) + expiresAt DateTime + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + dotWrapperId Int +} + +model DotFreezePeriod { + id Int @id @default(autoincrement()) + start DateTime + end DateTime + reason String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/src/prisma/schema/permission.prisma b/src/prisma/schema/permission.prisma index 8c441b520..87a056f33 100644 --- a/src/prisma/schema/permission.prisma +++ b/src/prisma/schema/permission.prisma @@ -136,6 +136,8 @@ enum Permission { COURSES_READ COURSES_ADMIN + + DOTS_ADMIN } model Role { diff --git a/src/prisma/schema/user.prisma b/src/prisma/schema/user.prisma index 00a5b899f..e0309e8df 100644 --- a/src/prisma/schema/user.prisma +++ b/src/prisma/schema/user.prisma @@ -32,6 +32,9 @@ model User { registeredAdmissionTrial AdmissionTrial[] @relation(name: "registeredBy") EventRegistration EventRegistration[] + dots DotWrapper[] @relation(name: "dot_user") + dotsAccused DotWrapper[] @relation(name: "dot_accuser") + // We need to explicitly mark the combination of 'id', 'username' and 'email' as // unique to make the relation to 'Credentials' work. @@unique([id, username, email]) diff --git a/src/services/dots/Authers.ts b/src/services/dots/Authers.ts new file mode 100644 index 000000000..3fdf8fbdf --- /dev/null +++ b/src/services/dots/Authers.ts @@ -0,0 +1,13 @@ +import { RequirePermission } from '@/auth/auther/RequirePermission' +import { RequirePermissionAndUserId } from '@/auth/auther/RequirePermissionAndUserId' +import { RequireUserIdOrPermission } from '@/auth/auther/RequireUserIdOrPermission' + +export const CreateDotAuther = RequirePermissionAndUserId.staticFields({ permission: 'DOTS_ADMIN' }) + +export const UpdateDotAuther = RequirePermission.staticFields({ permission: 'DOTS_ADMIN' }) + +export const DestroyDotAuther = RequirePermission.staticFields({ permission: 'DOTS_ADMIN' }) + +export const ReadDotForUserAuther = RequireUserIdOrPermission.staticFields({ permission: 'DOTS_ADMIN' }) + +export const ReadDotAuther = RequirePermission.staticFields({ permission: 'DOTS_ADMIN' }) diff --git a/src/services/dots/ConfigVars.ts b/src/services/dots/ConfigVars.ts new file mode 100644 index 000000000..e2366d89e --- /dev/null +++ b/src/services/dots/ConfigVars.ts @@ -0,0 +1,25 @@ +import type { Prisma } from '@prisma/client' + +export const DOT_BASE_DURATION = 1000 * 60 * 60 * 24 * 14 // 14 days + +export const DotWrapperWithDotsIncluder = { + dots: { + orderBy: { + expiresAt: 'desc' + } + }, + user: { + select: { + firstname: true, + lastname: true, + username: true + } + }, + accuser: { + select: { + firstname: true, + lastname: true, + username: true + } + } +} as const satisfies Prisma.DotWrapperInclude diff --git a/src/services/dots/Types.ts b/src/services/dots/Types.ts new file mode 100644 index 000000000..cb57a4663 --- /dev/null +++ b/src/services/dots/Types.ts @@ -0,0 +1,20 @@ +import type { Dot, DotWrapper, User } from '@prisma/client' + +export type DotDetails = { + userId: number | null, + onlyActive: boolean, +} + +export type DotCursor = { + id: number +} + +export type DotWithActive = Dot & { + active: boolean +} + +export type DotWrapperWithDots = DotWrapper & { + dots: DotWithActive[], + user: Pick, + accuser: Pick, +} diff --git a/src/services/dots/create.ts b/src/services/dots/create.ts new file mode 100644 index 000000000..3116e1ded --- /dev/null +++ b/src/services/dots/create.ts @@ -0,0 +1,39 @@ +import 'server-only' +import { Dots } from '.' +import { DOT_BASE_DURATION } from './ConfigVars' +import { createDotValidation } from './validation' +import { ServiceMethodHandler } from '@/services/ServiceMethodHandler' + +export const create = ServiceMethodHandler({ + withData: true, + validation: createDotValidation, + wantsToOpenTransaction: true, + handler: async (prisma, params: { accuserId: number }, { value, ...data }, session) => { + const activeDots = await Dots.readForUser.client(prisma).execute( + { params: { userId: data.userId, onlyActive: true }, session }, { withAuth: true } + ) + + const dotData : { expiresAt: Date }[] = [] + let prevExpiresAt = activeDots.length > 0 ? activeDots[activeDots.length - 1].expiresAt : new Date() + for (let i = 0; i < value; i++) { + //TODO: Take freezes into account + const expiresAt = new Date(prevExpiresAt.getTime() + DOT_BASE_DURATION) + dotData.push({ expiresAt }) + prevExpiresAt = expiresAt + } + await prisma.$transaction(async tx => { + const wrapper = await tx.dotWrapper.create({ + data: { + ...data, + accuserId: params.accuserId + } + }) + await tx.dot.createMany({ + data: dotData.map(dd => ({ + ...dd, + dotWrapperId: wrapper.id + })) + }) + }) + } +}) diff --git a/src/services/dots/index.ts b/src/services/dots/index.ts new file mode 100644 index 000000000..9f7481d15 --- /dev/null +++ b/src/services/dots/index.ts @@ -0,0 +1,36 @@ +import 'server-only' +import { CreateDotAuther, ReadDotAuther, ReadDotForUserAuther } from './Authers' +import { create } from './create' +import { readForUser, readWrappersForUser, readPage } from './read' +import { ServiceMethod } from '@/services/ServiceMethod' + +export const Dots = { + create: ServiceMethod({ + withData: true, + hasAuther: true, + auther: CreateDotAuther, + dynamicFields: ({ params }) => ({ userId: params.accuserId }), + serviceMethodHandler: create, + }), + readForUser: ServiceMethod({ + withData: false, + hasAuther: true, + auther: ReadDotForUserAuther, + dynamicFields: ({ params }) => ({ userId: params.userId }), + serviceMethodHandler: readForUser, + }), + readWrapperForUser: ServiceMethod({ + withData: false, + hasAuther: true, + auther: ReadDotForUserAuther, + dynamicFields: ({ params }) => ({ userId: params.userId }), + serviceMethodHandler: readWrappersForUser, + }), + readPage: ServiceMethod({ + withData: false, + hasAuther: true, + auther: ReadDotAuther, + dynamicFields: () => ({}), + serviceMethodHandler: readPage, + }) +} as const diff --git a/src/services/dots/read.ts b/src/services/dots/read.ts new file mode 100644 index 000000000..d358b7650 --- /dev/null +++ b/src/services/dots/read.ts @@ -0,0 +1,84 @@ +import 'server-only' +import { DotWrapperWithDotsIncluder } from './ConfigVars' +import { ServiceMethodHandler } from '@/services/ServiceMethodHandler' +import { cursorPageingSelection } from '@/services/paging/cursorPageingSelection' +import type { ReadPageInput } from '@/services/paging/Types' +import type { DotCursor, DotDetails } from './Types' + +/** + * This method reads all dots for a user + * @param userId - The user id to read dots for + * @returns All dots for the user in ascending order of expiration. i.e the dot that expires first will be first in the list + */ +export const readForUser = ServiceMethodHandler({ + withData: false, + handler: async (prisma, { userId, onlyActive }: { userId: number, onlyActive: boolean }) => prisma.dot.findMany({ + where: { + wrapper: { + userId, + }, + expiresAt: onlyActive ? { + gt: new Date() + } : undefined, + }, + orderBy: { + expiresAt: 'asc' + } + }) +}) + +export const readWrappersForUser = ServiceMethodHandler({ + withData: false, + handler: async (prisma, { userId }: { userId: number }) => { + const wrappers = await prisma.dotWrapper.findMany({ + where: { + userId + }, + include: DotWrapperWithDotsIncluder, + }) + + return wrappers.sort((a, b) => { + const latestA = Math.max(...a.dots.map(dot => new Date(dot.expiresAt).getTime())) + const latestB = Math.max(...b.dots.map(dot => new Date(dot.expiresAt).getTime())) + return latestB - latestA + }).map(wrapper => ({ + ...wrapper, + dots: extendWithActive(wrapper.dots) + })) + } +}) + +export const readPage = ServiceMethodHandler({ + withData: false, + handler: async (prisma, params: { + paging: ReadPageInput + }) => (await prisma.dotWrapper.findMany({ + ...cursorPageingSelection(params.paging.page), + where: { + userId: params.paging.details.userId ?? undefined, + dots: params.paging.details.onlyActive ? { + some: { + expiresAt: { + gt: new Date() + } + } + } : undefined + }, + orderBy: { + user: { + username: 'asc' + } + }, + include: DotWrapperWithDotsIncluder, + })).map(wrapper => ({ + ...wrapper, + dots: extendWithActive(wrapper.dots) + })) +}) + +function extendWithActive(dots: T[]) { + return dots.map(dot => ({ + ...dot, + active: dot.expiresAt > new Date() + })) +} diff --git a/src/services/dots/validation.ts b/src/services/dots/validation.ts new file mode 100644 index 000000000..6c3e3d4f5 --- /dev/null +++ b/src/services/dots/validation.ts @@ -0,0 +1,29 @@ +import { ValidationBase } from '@/services/Validation' +import { z } from 'zod' + +const baseDotValidation = new ValidationBase({ + type: { + value: z.coerce.number(), + reason: z.string(), + userId: z.coerce.number(), + }, + details: { + value: z.coerce.number().int().min( + 1, 'Verdi må være et positivt heltall' + ).max( + 100, 'Verdi kan ikke være større enn 100' + ), + reason: z.string().max(200, 'Begrunnelse kan ha maks 200 tegn').trim(), + userId: z.coerce.number().int(), + } +}) + +export const createDotValidation = baseDotValidation.createValidation({ + keys: ['value', 'reason', 'userId'], + transformer: data => data, +}) + +export const updateDotValidation = baseDotValidation.createValidationPartial({ + keys: ['value', 'reason'], + transformer: data => data, +}) diff --git a/src/services/error.ts b/src/services/error.ts index 2feeae63c..19be17e9c 100644 --- a/src/services/error.ts +++ b/src/services/error.ts @@ -1,25 +1,76 @@ import type { AuthStatus } from '@/auth/getUser' -export type ServerErrorCode = - | 'DUPLICATE' - | 'NOT FOUND' - | 'BAD PARAMETERS' - | 'UNKNOWN ERROR' - | 'JWT EXPIRED' - | 'JWT INVALID' - | 'SERVER ERROR' - | 'INVALID CONFIGURATION' - | 'NOT IMPLEMENTED' - | 'INVALID API KEY' - -export type ErrorCodes = ServerErrorCode | AuthStatus +export const errorCodes = [ + { + name: 'DUPLICATE', + httpCode: 409, + defaultMessage: 'En ressurs med samme navn eksisterer allerede', + }, + { + name: 'NOT FOUND', + httpCode: 404, + defaultMessage: 'Fant ikke ressursen', + }, + { + name: 'BAD PARAMETERS', + httpCode: 400, + defaultMessage: 'Feil i parametrene', + }, + { + name: 'UNKNOWN ERROR', + httpCode: 500, + defaultMessage: 'En ukjent feil har oppstått', + }, + { + name: 'JWT EXPIRED', + httpCode: 401, + defaultMessage: 'Din innlogging har utløpt', + }, + { + name: 'JWT INVALID', + httpCode: 401, + defaultMessage: 'Din innlogging er ugyldig', + }, + { + name: 'SERVER ERROR', + httpCode: 500, + defaultMessage: 'En serverfeil har oppstått', + }, + { + name: 'INVALID CONFIGURATION', + httpCode: 500, + defaultMessage: 'Konfigurasjonen er ugyldig', + }, + { + name: 'NOT IMPLEMENTED', + httpCode: 501, + defaultMessage: 'Funksjonen er ikke implementert', + }, + { + name: 'INVALID API KEY', + httpCode: 401, + defaultMessage: 'API-nøkkelen er ugyldig', + }, + { + name: 'UNAUTHORIZED', + httpCode: 403, + defaultMessage: 'Du har ikke tilgang til denne ressursen', + }, + { + name: 'UNAUTHENTICATED', + httpCode: 401, + defaultMessage: 'Du er ikke innlogget', + }, +] as const + +export type ErrorCode = typeof errorCodes[number]['name'] export type ErrorMessage = { path?: (number | string)[], message: string, } -export class Smorekopp extends Error { +export class Smorekopp extends Error { errorCode: ValidCodes errors: ErrorMessage[] @@ -34,8 +85,14 @@ export class Smorekopp extends Error { this.errors = parsedErrors ?? [] this.name = 'ServerError' } + + get httpCode() { + return errorCodes.find((code) => code.name === this.errorCode)?.httpCode ?? 500 + } } +//TODO: Rename this to ServiceError and actually start to use the serviceCausedError field. +export type ServerErrorCode = Exclude export class ServerError extends Smorekopp { public serviceCausedError: string | undefined constructor(errorCode: ServerErrorCode, errors: string | ErrorMessage[], serviceCausedError?: string) { diff --git a/src/services/permissionRoles/ConfigVars.ts b/src/services/permissionRoles/ConfigVars.ts index 4a88f90e3..6163cd2af 100644 --- a/src/services/permissionRoles/ConfigVars.ts +++ b/src/services/permissionRoles/ConfigVars.ts @@ -431,6 +431,11 @@ export const PermissionConfig = { name: 'Oppdatere skapreservasjoner', description: 'kan oppdatere skapreservasjoner', category: 'brukere' + }, + DOTS_ADMIN: { + name: 'Prikkadministrator', + description: 'kan administrere prikker', + category: 'brukere' } } satisfies Record diff --git a/src/services/users/Authers.ts b/src/services/users/Authers.ts index a136ef011..d1f516274 100644 --- a/src/services/users/Authers.ts +++ b/src/services/users/Authers.ts @@ -3,6 +3,8 @@ import { RequireUsernameOrPermission } from '@/auth/auther/RequireUsernameOrPerm export const ReadUserAuther = RequireUsernameOrPermission.staticFields({ permission: 'USERS_READ' }) +export const UserProfileUpdateAuther = RequireUsernameOrPermission.staticFields({ permission: 'USERS_UPDATE' }) + export const CreateUserAuther = RequirePermission.staticFields({ permission: 'USERS_CREATE' }) export const UpdateUserAuther = RequirePermission.staticFields({ permission: 'USERS_UPDATE' }) diff --git a/src/services/users/Types.ts b/src/services/users/Types.ts index 13a87c5ee..3f26c8f9c 100644 --- a/src/services/users/Types.ts +++ b/src/services/users/Types.ts @@ -45,7 +45,7 @@ export type UserCursor = { } export type Profile = { - user: UserFiltered & { image: Image | null, bio: string }, + user: UserFiltered & { image: Image, bio: string }, memberships: MembershipFiltered[], permissions: Permission[], } diff --git a/src/services/users/read.ts b/src/services/users/read.ts index cb7bdbb5c..fc417df66 100644 --- a/src/services/users/read.ts +++ b/src/services/users/read.ts @@ -1,4 +1,5 @@ import { maxNumberOfGroupsInFilter, standardMembershipSelection, userFilterSelection } from './ConfigVars' +import { readSpecialImage } from '@/services/images/read' import { ServiceMethodHandler } from '@/services/ServiceMethodHandler' import { ServerError } from '@/services/error' import { prismaCall } from '@/services/prismaCall' @@ -139,6 +140,7 @@ export async function readUserOrNull(where: readUserWhere): Promise } export async function readUserProfile(username: string): Promise { + const defaultProfileImage = await readSpecialImage('DEFAULT_PROFILE_IMAGE') const user = await prismaCall(() => prisma.user.findUniqueOrThrow({ where: { username }, select: { @@ -146,8 +148,10 @@ export async function readUserProfile(username: string): Promise { bio: true, image: true, }, + })).then(u => ({ + ...u, + image: u.image || defaultProfileImage })) - const memberships = await readMembershipsOfUser(user.id) const permissions = await readPermissionsOfUser(user.id) @@ -164,7 +168,10 @@ export const readProfile = ServiceMethodHandler({ bio: true, image: true, }, - }) + }).then(async u => ({ + ...u, + image: u.image || await readSpecialImage('DEFAULT_PROFILE_IMAGE') + })) const memberships = await readMembershipsOfUser(user.id) const permissions = await readPermissionsOfUser(user.id) diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index c2d7fb65a..745a352a4 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -29,7 +29,7 @@ } th { background-color: colors.$gray-200; - color: black; + color: colors.$black; } tr:nth-child(even) { background-color: colors.$gray-100; @@ -69,6 +69,27 @@ } } +@mixin roundAdminSvgBtn() { + width: 45px; + height: 45px; + background-color: colors.$primary; + border: none; + border-radius: 50%; + display: grid; + place-items: center; + text-decoration: none; + svg { + color: colors.$black; + transition: color 0.6s; + width: 31px; + height: 31px; + } + &:hover svg { + color: colors.$gray-500; + cursor: pointer; + } +} + @mixin roundBtn($color: colors.$white) { width: 20px; height: 20px;