diff --git a/server/lib/orcasite/accounts/user.ex b/server/lib/orcasite/accounts/user.ex index 3bb9b9db..43d52970 100644 --- a/server/lib/orcasite/accounts/user.ex +++ b/server/lib/orcasite/accounts/user.ex @@ -30,6 +30,15 @@ defmodule Orcasite.Accounts.User do constraints allow_empty?: false, trim?: true end + # Profile fields + attribute :is_scientist, :boolean, default: false, allow_nil?: false, public?: true + attribute :organization, :string, allow_nil?: true, public?: true + + # Preferences + attribute :volunteering, :boolean, default: false, allow_nil?: false, public?: true + attribute :user_testing, :boolean, default: false, allow_nil?: false, public?: true + attribute :newsletter, :boolean, default: false, allow_nil?: false, public?: true + create_timestamp :inserted_at update_timestamp :updated_at end @@ -72,6 +81,10 @@ defmodule Orcasite.Accounts.User do bypass AshAuthentication.Checks.AshAuthenticationInteraction do authorize_if always() end + + policy action_type(:read) do + authorize_if expr(id == ^actor(:id)) + end policy action(:current_user) do authorize_if always() @@ -92,6 +105,14 @@ defmodule Orcasite.Accounts.User do bypass action(:password_reset_with_password) do authorize_if always() end + + policy action(:update_profile) do + authorize_if expr(id == ^actor(:id)) + end + + policy action(:update_preferences) do + authorize_if expr(id == ^actor(:id)) + end end actions do @@ -105,6 +126,27 @@ defmodule Orcasite.Accounts.User do get? true manual Orcasite.Accounts.Actions.CurrentUserRead end + + update :update_profile do + accept [ + :username, + :first_name, + :last_name, + :is_scientist, + :organization + ] + end + + update :update_preferences do + argument :live_notifications, :boolean, default: false, allow_nil?: false + accept [ + :volunteering, + :user_testing, + :newsletter + ] + + # TODO: When live_notifications is true, create a subscription for the user + end end code_interface do @@ -115,6 +157,8 @@ defmodule Orcasite.Accounts.User do define :by_email, args: [:email] define :request_password_reset_with_password define :password_reset_with_password + define :update_profile + define :update_preferences end admin do @@ -141,7 +185,24 @@ defmodule Orcasite.Accounts.User do end mutations do - create :register_with_password, :register_with_password + create :register_with_password, :register_with_password do + modify_resolution {__MODULE__, :after_registration, []} + end + update :update_user_profile, :update_profile + update :update_user_preferences, :update_preferences + end + end + + def after_registration(resolution, _query, result) do + result + |> case do + {:ok, user} -> + resolution + |> Map.update!(:context, fn ctx -> + Map.put(ctx, :current_user, user) + end) + _ -> + resolution end end end diff --git a/server/lib/orcasite_web/graphql/types/accounts.ex b/server/lib/orcasite_web/graphql/types/accounts.ex index d001893f..79c32023 100644 --- a/server/lib/orcasite_web/graphql/types/accounts.ex +++ b/server/lib/orcasite_web/graphql/types/accounts.ex @@ -10,7 +10,7 @@ defmodule OrcasiteWeb.Graphql.Types.Accounts do object :sign_in_with_password_result do field :user, :user - field :errors, list_of(:mutation_error) + field :errors, non_null(list_of(non_null(:mutation_error))) end input_object :request_password_reset_input do @@ -25,7 +25,7 @@ defmodule OrcasiteWeb.Graphql.Types.Accounts do object :password_reset_result do field :user, :user - field :errors, list_of(:mutation_error) + field :errors, non_null(list_of(non_null(:mutation_error))) end object :accounts_user_mutations do diff --git a/server/priv/repo/migrations/20241213011155_add_profile_and_preferences_to_users.exs b/server/priv/repo/migrations/20241213011155_add_profile_and_preferences_to_users.exs new file mode 100644 index 00000000..29767964 --- /dev/null +++ b/server/priv/repo/migrations/20241213011155_add_profile_and_preferences_to_users.exs @@ -0,0 +1,13 @@ +defmodule Orcasite.Repo.Migrations.AddProfileAndPreferencesToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :is_scientist, :boolean, default: false, null: false + add :organization, :string + add :volunteering, :boolean, default: false, null: false + add :user_testing, :boolean, default: false, null: false + add :newsletter, :boolean, default: false, null: false + end + end +end diff --git a/ui/package-lock.json b/ui/package-lock.json index cff8f577..dbbfb30a 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -14,6 +14,7 @@ "@emotion/styled": "^11.13.0", "@fontsource/montserrat": "^5.1.0", "@fontsource/mukta": "^5.1.0", + "@hookform/resolvers": "^3.9.1", "@mui/icons-material": "^6.1.4", "@mui/material": "^6.1.0", "@mui/material-nextjs": "^6.1.4", @@ -29,12 +30,14 @@ "react-dom": "18.3.1", "react-fast-marquee": "^1.6.5", "react-ga4": "^2.1.0", + "react-hook-form": "^7.54.0", "react-leaflet": "^4.2.1", "react-vega": "^7.6.0", "sharp": "^0.33.5", "vega-lite": "^5.21.0", "video.js": "^8.18.1", - "videojs-offset": "^2.1.3" + "videojs-offset": "^2.1.3", + "zod": "^3.24.1" }, "devDependencies": { "@graphql-codegen/add": "^5.0.3", @@ -2269,6 +2272,15 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@hookform/resolvers": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz", + "integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -9459,6 +9471,22 @@ "resolved": "https://registry.npmjs.org/react-ga4/-/react-ga4-2.1.0.tgz", "integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==" }, + "node_modules/react-hook-form": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.0.tgz", + "integrity": "sha512-PS05+UQy/IdSbJNojBypxAo9wllhHgGmyr8/dyGQcPoiMf3e7Dfb9PWYVRco55bLbxH9S+1yDDJeTdlYCSxO3A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -11812,6 +11840,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/ui/package.json b/ui/package.json index b4e8d92e..6b06a71c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -22,6 +22,7 @@ "@emotion/styled": "^11.13.0", "@fontsource/montserrat": "^5.1.0", "@fontsource/mukta": "^5.1.0", + "@hookform/resolvers": "^3.9.1", "@mui/icons-material": "^6.1.4", "@mui/material": "^6.1.0", "@mui/material-nextjs": "^6.1.4", @@ -37,12 +38,14 @@ "react-dom": "18.3.1", "react-fast-marquee": "^1.6.5", "react-ga4": "^2.1.0", + "react-hook-form": "^7.54.0", "react-leaflet": "^4.2.1", "react-vega": "^7.6.0", "sharp": "^0.33.5", "vega-lite": "^5.21.0", "video.js": "^8.18.1", - "videojs-offset": "^2.1.3" + "videojs-offset": "^2.1.3", + "zod": "^3.24.1" }, "devDependencies": { "@graphql-codegen/add": "^5.0.3", diff --git a/ui/src/components/Header/AccountMenu.tsx b/ui/src/components/Header/AccountMenu.tsx new file mode 100644 index 00000000..c412b3ae --- /dev/null +++ b/ui/src/components/Header/AccountMenu.tsx @@ -0,0 +1,103 @@ +import { Logout, Person, Settings } from "@mui/icons-material"; +import { + Box, + Button, + IconButton, + ListItemIcon, + Menu, + MenuItem, + SxProps, + Theme, + Typography, +} from "@mui/material"; +import router from "next/router"; +import { useState } from "react"; + +import Link from "@/components/Link"; +import { useAuth } from "@/hooks/useAuth"; + +export default function AccountMenu({ sx }: { sx?: SxProps }) { + const { user, signOut } = useAuth(); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleSignOut = () => { + handleClose(); + signOut({}); + router.push("/"); + }; + + if (!user) { + return ( + + + + ); + } + + return ( + <> + + + + + + + {[user.firstName, user.lastName].filter(Boolean).join(" ") ?? + user.username ?? + user.email} + + + {user.email} + + + + + + + Settings + + + + + + Sign out + + + + ); +} diff --git a/ui/src/components/Header/Brand.tsx b/ui/src/components/Header/Brand.tsx new file mode 100644 index 00000000..cff797c4 --- /dev/null +++ b/ui/src/components/Header/Brand.tsx @@ -0,0 +1,37 @@ +import { Typography } from "@mui/material"; +import Image from "next/image"; + +import Link from "@/components/Link"; +import wordmark from "@/public/wordmark/wordmark-white.svg"; +import { analytics } from "@/utils/analytics"; + +export default function Brand({ onClick }: { onClick?: () => void }) { + return ( + + { + if (onClick) onClick(); + analytics.nav.logoClicked(); + }} + > + Orcasound + + + ); +} diff --git a/ui/src/components/Header/Desktop.tsx b/ui/src/components/Header/Desktop.tsx new file mode 100644 index 00000000..6c247878 --- /dev/null +++ b/ui/src/components/Header/Desktop.tsx @@ -0,0 +1,80 @@ +import { Notifications } from "@mui/icons-material"; +import { Box, Button } from "@mui/material"; + +import Link from "@/components/Link"; +import { displayDesktopOnly } from "@/styles/responsive"; +import { analytics } from "@/utils/analytics"; + +import UserMenu from "./AccountMenu"; +import Brand from "./Brand"; + +export default function Desktop() { + const pages = [ + { + label: "About us", + url: "https://www.orcasound.net/", + onClick: () => analytics.nav.aboutTabClicked(), + }, + { + label: "Send feedback", + url: "https://forms.gle/wKpAnxzUh9a5LMfd7", + onClick: () => analytics.nav.feedbackTabClicked(), + }, + ]; + return ( + + + + + {pages.map((page) => ( + + ))} + analytics.nav.notificationsClicked()} + > + + + + + + + ); +} diff --git a/ui/src/components/Header/Header.tsx b/ui/src/components/Header/Header.tsx new file mode 100644 index 00000000..d5c0baaa --- /dev/null +++ b/ui/src/components/Header/Header.tsx @@ -0,0 +1,25 @@ +import { AppBar, Toolbar } from "@mui/material"; + +import Desktop from "./Desktop"; +import Mobile from "./Mobile"; + +export default function Header({ + onBrandClick, +}: { + onBrandClick?: () => void; +}) { + return ( + theme.zIndex.drawer + 1, + }} + > + + + + + + ); +} diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header/Mobile.tsx similarity index 52% rename from ui/src/components/Header.tsx rename to ui/src/components/Header/Mobile.tsx index 569558bb..92cd19e3 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header/Mobile.tsx @@ -2,13 +2,11 @@ import { Close, Feedback, Home, - Menu, + Menu as MenuIcon, Notifications, } from "@mui/icons-material"; import { - AppBar, Box, - Button, Divider, Drawer, IconButton, @@ -17,39 +15,18 @@ import { ListItemButton, ListItemIcon, ListItemText, - Toolbar, - Typography, } from "@mui/material"; import Image from "next/image"; import { useState } from "react"; -import Link from "@/components/Link"; import wordmark from "@/public/wordmark/wordmark-white.svg"; -import { displayDesktopOnly, displayMobileOnly } from "@/styles/responsive"; +import { displayMobileOnly } from "@/styles/responsive"; import { analytics } from "@/utils/analytics"; -export default function Header({ - onBrandClick, -}: { - onBrandClick?: () => void; -}) { - return ( - theme.zIndex.drawer + 1, - }} - > - - - - - - ); -} +import UserMenu from "./AccountMenu"; +import Brand from "./Brand"; -function Mobile({ +export default function Mobile({ window, onBrandClick, }: { @@ -103,9 +80,10 @@ function Mobile({ color="inherit" onClick={handleMenuToggle} > - {menuIsOpen ? : } + {menuIsOpen ? : } +