diff --git a/admin/schema_updates/2024-12-11-add-user-table.sql b/admin/schema_updates/2024-12-11-add-user-table.sql new file mode 100644 index 00000000..ac31b754 --- /dev/null +++ b/admin/schema_updates/2024-12-11-add-user-table.sql @@ -0,0 +1,18 @@ +BEGIN; + +CREATE TABLE "user" ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY, + name TEXT NOT NULL, + password TEXT NOT NULL, + email TEXT UNIQUE, + unconfirmed_email TEXT, + email_confirmed_at TIMESTAMP WITH TIME ZONE, + member_since TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMP WITH TIME ZONE, + last_updated TIMESTAMP WITH TIME ZONE, + deleted BOOLEAN +); + +ALTER TABLE "user" ADD CONSTRAINT user_pkey PRIMARY KEY (id); + +COMMIT; diff --git a/admin/schema_updates/2024-12-12-link-user-and-supporter-table.sql b/admin/schema_updates/2024-12-12-link-user-and-supporter-table.sql new file mode 100644 index 00000000..60ff53fe --- /dev/null +++ b/admin/schema_updates/2024-12-12-link-user-and-supporter-table.sql @@ -0,0 +1,10 @@ +BEGIN; + +ALTER TABLE supporter ADD COLUMN user_id INTEGER; +UPDATE supporter SET user_id = musicbrainz_row_id; + +ALTER TABLE supporter ADD CONSTRAINT supporter_user_id_fkey + FOREIGN KEY (user_id) REFERENCES "user" (id) + ON UPDATE CASCADE ON DELETE SET NULL; + +COMMIT; diff --git a/admin/schema_updates/2025-02-15-add-moderation-log.sql b/admin/schema_updates/2025-02-15-add-moderation-log.sql new file mode 100644 index 00000000..84936f60 --- /dev/null +++ b/admin/schema_updates/2025-02-15-add-moderation-log.sql @@ -0,0 +1,22 @@ +CREATE TYPE moderation_action_type AS ENUM ('block', 'unblock'); + +BEGIN; + +ALTER TABLE "user" ADD COLUMN is_blocked BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE moderation_log ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY, + user_id INTEGER NOT NULL, + moderator_id INTEGER NOT NULL, + action moderation_action_type NOT NULL, + reason TEXT NOT NULL, + timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); +ALTER TABLE moderation_log ADD CONSTRAINT moderation_log_pkey PRIMARY KEY (id); +ALTER TABLE moderation_log ADD CONSTRAINT moderation_log_user_id_fkey FOREIGN KEY (user_id) REFERENCES "user" (id); +ALTER TABLE moderation_log ADD CONSTRAINT moderation_log_moderator_id_fkey FOREIGN KEY (moderator_id) REFERENCES "user" (id); +CREATE INDEX moderation_log_user_id_idx ON moderation_log (user_id); + +ALTER TABLE "user" ADD COLUMN login_id UUID NOT NULL; +CREATE UNIQUE INDEX user_login_id_idx ON "user" (login_id); + +COMMIT; diff --git a/admin/schema_updates/2025-add-nonce-to-authorization-code.sql b/admin/schema_updates/2025-07-01-add-nonce-to-authorization-code.sql similarity index 100% rename from admin/schema_updates/2025-add-nonce-to-authorization-code.sql rename to admin/schema_updates/2025-07-01-add-nonce-to-authorization-code.sql diff --git a/admin/schema_updates/2025-09-18-add-moderation-action-types.sql b/admin/schema_updates/2025-09-18-add-moderation-action-types.sql new file mode 100644 index 00000000..373dea6c --- /dev/null +++ b/admin/schema_updates/2025-09-18-add-moderation-action-types.sql @@ -0,0 +1,2 @@ +ALTER TYPE moderation_action_type ADD VALUE 'comment'; +ALTER TYPE moderation_action_type ADD VALUE 'verify_email'; diff --git a/admin/sql/create_foreign_keys.sql b/admin/sql/create_foreign_keys.sql index b0fc6ca8..202215a7 100644 --- a/admin/sql/create_foreign_keys.sql +++ b/admin/sql/create_foreign_keys.sql @@ -5,6 +5,10 @@ ALTER TABLE token REFERENCES supporter (id) MATCH SIMPLE ON UPDATE CASCADE ON DELETE SET NULL; +ALTER TABLE supporter ADD CONSTRAINT supporter_user_id_fkey + FOREIGN KEY (user_id) REFERENCES "user" (id) + ON UPDATE CASCADE ON DELETE SET NULL; + ALTER TABLE supporter ADD CONSTRAINT supporter_tier_id_fkey FOREIGN KEY (tier_id) REFERENCES tier (id) MATCH SIMPLE @@ -17,7 +21,7 @@ ALTER TABLE dataset_supporter ALTER TABLE dataset_supporter ADD CONSTRAINT dataset_supporter_dataset_id_fkey FOREIGN KEY (dataset_id) - REFERENCES "dataset" (id) MATCH SIMPLE + REFERENCES dataset (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; ALTER TABLE token_log @@ -35,4 +39,14 @@ ALTER TABLE access_log REFERENCES token (value) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; +ALTER TABLE moderation_log + ADD CONSTRAINT moderation_log_user_id_fkey FOREIGN KEY (user_id) + REFERENCES "user" (id) MATCH SIMPLE + ON UPDATE CASCADE ON DELETE SET NULL; + +ALTER TABLE moderation_log + ADD CONSTRAINT moderation_log_moderator_id_fkey FOREIGN KEY (moderator_id) + REFERENCES "user" (id) MATCH SIMPLE + ON UPDATE CASCADE ON DELETE SET NULL; + COMMIT; diff --git a/admin/sql/create_indexes.sql b/admin/sql/create_indexes.sql index be997d2e..acd9b057 100644 --- a/admin/sql/create_indexes.sql +++ b/admin/sql/create_indexes.sql @@ -2,4 +2,6 @@ BEGIN; -- TODO: Add some, if needed. +CREATE INDEX moderation_log_user_id_idx ON moderation_log (user_id); + COMMIT; diff --git a/admin/sql/create_primary_keys.sql b/admin/sql/create_primary_keys.sql index 0cc42a1f..03ec1294 100644 --- a/admin/sql/create_primary_keys.sql +++ b/admin/sql/create_primary_keys.sql @@ -1,5 +1,7 @@ BEGIN; +ALTER TABLE "user" ADD CONSTRAINT user_pkey PRIMARY KEY (id); +ALTER TABLE moderation_log ADD CONSTRAINT moderation_log_pkey PRIMARY KEY (id); ALTER TABLE tier ADD CONSTRAINT tier_pkey PRIMARY KEY (id); ALTER TABLE supporter ADD CONSTRAINT supporter_pkey PRIMARY KEY (id); ALTER TABLE token ADD CONSTRAINT token_pkey PRIMARY KEY (value); diff --git a/admin/sql/create_tables.sql b/admin/sql/create_tables.sql index fbdbba9c..5cf8ccb5 100644 --- a/admin/sql/create_tables.sql +++ b/admin/sql/create_tables.sql @@ -1,5 +1,29 @@ BEGIN; +CREATE TABLE "user" ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY, + login_id UUID NOT NULL, + name TEXT NOT NULL, + password TEXT NOT NULL, + email TEXT UNIQUE, + unconfirmed_email TEXT, + email_confirmed_at TIMESTAMP WITH TIME ZONE, + member_since TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMP WITH TIME ZONE, + last_updated TIMESTAMP WITH TIME ZONE, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + is_blocked BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE moderation_log ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY, + user_id INTEGER NOT NULL, + moderator_id INTEGER NOT NULL, + action moderation_action_type NOT NULL, + reason TEXT NOT NULL, + timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + CREATE TABLE tier ( id SERIAL NOT NULL, -- PK name CHARACTER VARYING NOT NULL, @@ -20,12 +44,10 @@ CREATE TABLE dataset ( CREATE TABLE supporter ( id SERIAL NOT NULL, -- PK is_commercial BOOLEAN NOT NULL, - musicbrainz_id CHARACTER VARYING UNIQUE, - musicbrainz_row_id INTEGER UNIQUE, + user_id INTEGER UNIQUE, created TIMESTAMP WITH TIME ZONE, state state_types NOT NULL, contact_name CHARACTER VARYING NOT NULL, - contact_email CHARACTER VARYING NOT NULL, data_usage_desc TEXT, org_name CHARACTER VARYING, logo_filename CHARACTER VARYING, diff --git a/admin/sql/create_types.sql b/admin/sql/create_types.sql index 877be599..a60cd510 100644 --- a/admin/sql/create_types.sql +++ b/admin/sql/create_types.sql @@ -1,3 +1,7 @@ +BEGIN; + +CREATE TYPE moderation_action_type AS ENUM ('block', 'unblock', 'comment'); + CREATE TYPE payment_method_types AS ENUM ( 'stripe', 'paypal', @@ -29,3 +33,5 @@ CREATE TYPE dataset_project_type AS ENUM ( 'listenbrainz', 'critiquebrainz' ); + +COMMIT; diff --git a/admin/sql/drop_tables.sql b/admin/sql/drop_tables.sql index edad1384..1946a2ce 100644 --- a/admin/sql/drop_tables.sql +++ b/admin/sql/drop_tables.sql @@ -1,10 +1,12 @@ BEGIN; +DROP TABLE IF EXISTS "user" CASCADE; DROP TABLE IF EXISTS payment CASCADE; DROP TABLE IF EXISTS oauth_grant CASCADE; DROP TABLE IF EXISTS oauth_token CASCADE; DROP TABLE IF EXISTS oauth_client CASCADE; DROP TABLE IF EXISTS access_log CASCADE; +DROP TABLE IF EXISTS moderation_log CASCADE; DROP TABLE IF EXISTS token_log CASCADE; DROP TABLE IF EXISTS token CASCADE; DROP TABLE IF EXISTS supporter CASCADE; diff --git a/admin/sql/oauth/create_tables.sql b/admin/sql/oauth/create_tables.sql index 32beab33..133f7034 100644 --- a/admin/sql/oauth/create_tables.sql +++ b/admin/sql/oauth/create_tables.sql @@ -1,3 +1,5 @@ +BEGIN; + CREATE schema oauth; CREATE TABLE oauth.scope ( @@ -109,3 +111,5 @@ CREATE TABLE oauth.l_code_scope ( FOREIGN KEY(code_id) REFERENCES oauth.code (id) ON DELETE CASCADE, FOREIGN KEY(scope_id) REFERENCES oauth.scope (id) ON DELETE CASCADE ); + +COMMIT; diff --git a/config.py.example b/config.py.example index 01c94cd3..2c541193 100644 --- a/config.py.example +++ b/config.py.example @@ -1,9 +1,19 @@ +from datetime import timedelta + # CUSTOM CONFIGURATION DEBUG = True # set to False in production mode SECRET_KEY = "CHANGE_THIS" +EMAIL_VERIFICATION_SECRET_KEY = "CHANGE THIS" +EMAIL_VERIFICATION_EXPIRY = timedelta(hours=24) +EMAIL_RESET_PASSWORD_EXPIRY = timedelta(hours=24) + +# Bcrypt +BCRYPT_HASH_PREFIX = "2a" +BCRYPT_LOG_ROUNDS = 12 + # DATABASE SQLALCHEMY_DATABASE_URI = "postgresql://metabrainz:metabrainz@meb_db:5432/metabrainz" SQLALCHEMY_MUSICBRAINZ_URI = "" diff --git a/consul_config.py.ctmpl b/consul_config.py.ctmpl index 766e7e92..66039627 100644 --- a/consul_config.py.ctmpl +++ b/consul_config.py.ctmpl @@ -10,9 +10,19 @@ {{- end -}} {{- end -}} +from datetime import timedelta + SECRET_KEY = '''{{template "KEY" "secret_key"}}''' DEBUG = False +EMAIL_VERIFICATION_SECRET_KEY = '''{{template "KEY" "email_verification_secret_key"}}''' +EMAIL_VERIFICATION_EXPIRY = timedelta(hours=24) +EMAIL_RESET_PASSWORD_EXPIRY = timedelta(hours=24) + +# Bcrypt +BCRYPT_HASH_PREFIX = "2a" +BCRYPT_LOG_ROUNDS = 12 + {{if service "pgbouncer-master"}} {{with index (service "pgbouncer-master") 0}} SQLALCHEMY_DATABASE_URI = "postgresql://{{template "KEY" "postgresql/username"}}:{{template "KEY" "postgresql/password"}}@{{.Address}}:{{.Port}}/{{template "KEY" "postgresql/db_name"}}" diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 8b3a1677..ddf57d3c 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -9,7 +9,9 @@ services: context: .. dockerfile: Dockerfile target: metabrainz-dev - command: python manage.py runserver -h 0.0.0.0 -p 8000 + command: flask run --debug -h 0.0.0.0 -p 8000 + environment: + FLASK_APP: "metabrainz:create_app()" volumes: - ../data/replication_packets:/data/replication_packets - ../data/json_dumps:/data/json_dumps diff --git a/frontend/css/auth-page.less b/frontend/css/auth-page.less new file mode 100644 index 00000000..331a8971 --- /dev/null +++ b/frontend/css/auth-page.less @@ -0,0 +1,107 @@ +#auth-page { + font-style: normal; + font-weight: 400; + min-height: 500px; + background: linear-gradient(90deg, #3b9766 0%, #ffa500 100%); + margin: 0 -1em; + padding: 2em; + + .form-label { + font-weight: normal; + } + .label-with-link { + display: flex; + justify-content: space-between; + width: 100%; + label { + flex-shrink: 0; + } + } + .form-label-link { + text-align: right; + } + + .auth-page-container { + max-width: 600px; + margin-left: auto; + margin-right: auto; + } + .auth-card-container { + background: #e7e7e7; + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.2); + border-radius: 3px; + } + .auth-card { + h1, + h2, + h3, + h4, + h5, + h6 { + font-weight: bold; + } + background: #ffffff; + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.2); + padding: 1rem 6rem; + border-radius: 3px; + @media screen and (max-width: @screen-xs-max) { + padding: 1rem 3rem; + } + } + .auth-card-bottom { + display: flex; + align-items: center; + justify-content: space-between; + } + .auth-card-footer { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + padding: 1rem; + font-size: 1.3rem; + line-height: 1.6rem; + color: #808080; + } + .main-action-button { + display: block; + font-size: 1.1em; + margin: 1em auto; + } + .modal-content { + padding: 1.5em; + } +} + +#conditions-modal { + font-size: initial; +} +@icon-pill-size: 50px; +@icon-logo-size: 40px; +@icon-pill-padding: 8px; +.icon-pills { + display: flex; + justify-content: space-evenly; + margin-bottom: 2rem; + .icon-pill { + background: #d9d9d9; + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.2); + text-align: center; + width: @icon-pill-size; + height: @icon-pill-size; + border-radius: @icon-pill-size; + padding: @icon-pill-padding; + display: flex; + align-items: center; + justify-content: flex-start; + overflow: hidden; + transition: width 0.42s; + &:hover { + width: 160px; // Fallback + width: calc(attr(data-logo-width px) + @icon-pill-padding * 2); + } + img { + height: @icon-logo-size; + max-width: unset; + } + } +} diff --git a/frontend/css/main.less b/frontend/css/main.less index 0d642ed6..56c47162 100644 --- a/frontend/css/main.less +++ b/frontend/css/main.less @@ -1,5 +1,6 @@ @import "theme/theme.less"; @import "carousel.less"; +@import "auth-page.less"; // fixme: need to make it configurable for production and development and meb.org container and oauth container @icon-font-path:"/static/fonts/"; diff --git a/frontend/css/theme/boostrap/boostrap.less b/frontend/css/theme/boostrap/boostrap.less index c55b2fd9..8a7204e0 100644 --- a/frontend/css/theme/boostrap/boostrap.less +++ b/frontend/css/theme/boostrap/boostrap.less @@ -40,7 +40,7 @@ @import "panels.less"; //@import "responsive-embed.less"; @import "wells.less"; -//@import "close.less"; +@import "close.less"; // Components w/ JavaScript @import "modals.less"; diff --git a/frontend/img/logos/listenbrainz.svg b/frontend/img/logos/listenbrainz.svg index 09454870..631b161b 100644 --- a/frontend/img/logos/listenbrainz.svg +++ b/frontend/img/logos/listenbrainz.svg @@ -1 +1,38 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/js/src/Profile.tsx b/frontend/js/src/Profile.tsx new file mode 100644 index 00000000..41576090 --- /dev/null +++ b/frontend/js/src/Profile.tsx @@ -0,0 +1,372 @@ +import React, { JSX, useCallback, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { getPageProps } from "./utils"; + +type ProfileProps = { + user: User; + csrf_token?: string; +}; + +type EmailProps = { + label: string; + is_email_confirmed: boolean; + email: string; + csrf_token?: string; +}; + +function Email({ + label, + is_email_confirmed, + email, + csrf_token, +}: EmailProps): JSX.Element { + return ( + <> + {label}: {email}{" "} + {is_email_confirmed ? ( + + Verified + + ) : ( + <> + + Unverified + +
+ + +
+ + )} +
+ + ); +} + +function SupporterProfile({ user, csrf_token }: ProfileProps) { + const { email, is_email_confirmed, supporter } = user; + const { + is_commercial, + tier, + state, + contact_name, + org_name, + website_url, + api_url, + datasets, + good_standing, + token, + } = supporter!!; + const [currentToken, setCurrentToken] = useState(token); + + const regenerateToken = useCallback(async () => { + if ( + !currentToken || + // eslint-disable-next-line no-alert + window.confirm( + "Are you sure you want to generate new access token? Current token will be revoked!" + ) + ) { + const response = await fetch("/supporters/profile/regenerate-token", { + method: "POST", + }); + if (!response.ok) { + // eslint-disable-next-line no-alert + window.alert("Failed to generate new access token!"); + } else { + const data = await response.json(); + setCurrentToken(data.token); + } + } + }, [currentToken]); + + let applicationState; + if (state === "rejected") { + applicationState = ( + <> +

+ + Your application for using the Live Data Feed has been rejected. + +

+

+ You do not have permission to use our data in a public commercial + product. +

+ + ); + } else if (state === "pending") { + applicationState = ( + <> +

+ Your application for using the Live Data Feed is still pending. +

+

+ You may use our data and APIs for evaluation/development purposes + while your application is pending. +

+ + ); + } else if (state === "waiting") { + applicationState = ( + <> +

+ + Your application for using the Live Data Feed is waiting to finalize + our support agreement. + +

+

+ You may use our data and APIs for evaluation and development purposes, + but you may not use the data in a public commercial product. Once you + are nearing the public release of a product that contains our data, + please + contact us again to finalize our support + agreement. +

+ + ); + } else if (!good_standing) { + applicationState = ( + <> +

+ Your use of the Live Data Feed is pending suspension. +

+

+ Your account is in bad standing, which means that you are more than 60 + days behind in support payments. If you think this is a mistake, + please contact us. +

+ + ); + } else { + applicationState =

Unknown. :(

; + } + + let stateClass; + if (state === "active") { + stateClass = "text-success"; + } else if (state === "rejected") { + stateClass = "text-danger"; + } else if (state === "pending") { + stateClass = "text-primary"; + } else { + stateClass = "text-warning"; + } + return ( + <> +

+ Type: {is_commercial ? "Commercial" : "Non-commercial"} +
+ {is_commercial && ( + <> + Tier: {tier.name} +
+ + )} + State: + {state.toUpperCase()} +

+ {!is_commercial && ( +

+ NOTE: If you would like to change your account from non-commercial to + commercial, please + contact us. +

+ )} +
+ {is_commercial && ( +
+

Organization information

+

+ Name:{" "} + {org_name || Unspecified} +
+ Website URL:{" "} + {website_url || Unspecified} +
+ API URL:{" "} + {api_url || Unspecified} +
+

+

+ Please contact us if you wish for us to + update this information. +

+
+ )} +
+

Contact information

+

+ Contact Name: {contact_name} +
+ + {!is_commercial && ( + <> + Datasets used:{" "} + {datasets.map((d) => d.name).join(", ")} +
+ + )} +

+

+ + {is_commercial + ? "Edit contact information" + : "Edit datasets/contact information"} + +

+
+
+ {is_commercial && + ((state === "active" || state === "limited") && good_standing ? ( + <> +

Data use permission granted

+
+

+ Your support agreement has been completed -- thank you! +

+

+ You have permission to use any of the data published by the + MetaBrainz Foundation. This includes data dumps released under a + Creative Commons non-commercial license. Thank you for your + support! +

+

+ Note 1: If your support falls behind by more than 60 days, this + permission may be withdrawn. You can always check your current + permission status on this page. +

+

+ Note 2: The IP addresses from which replication packets for the + Live Data Feed are downloaded are logged. +

+
+ + ) : ( + <> +

Limited/no data use permission granted

+
{applicationState}
+ + ))} + {(state === "active" || state === "limited") && good_standing && ( + <> +

Data Download

+
+ {!is_commercial && ( +

+ Thank you for registering with us -- we really appreciate it! +

+ )} +

Please proceed to our download page to download our datasets:

+

+ + Download Datasets + +

+
+ + )} + {state === "active" && ( + <> +

Live Data Feed Access token

+

+ This access token should be considered private. Don't check + this token into any publicly visible version control systems and + similar places. If the token has been exposed, you should + immediately immediately generate a new one! When you generate a new + token, your token is revoked. +

+
+

+

+ {currentToken || "[ there is no valid token currently ]"} +
+

+

+ +

+
+

+ See the API documentation for more information. +

+ + )} + + ); +} + +function UserProfile({ user, csrf_token }: ProfileProps): JSX.Element { + const { name, is_email_confirmed, email } = user; + return ( + <> +

Contact information

+

+ Name: {name} +
+ +

+

+ + Edit information + +

+ + ); +} + +function Profile({ user, csrf_token }: ProfileProps): JSX.Element { + return ( + <> +

Your Profile

+ {user.supporter ? ( + + ) : ( + + )} + + ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { user, csrf_token } = reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render(); +}); diff --git a/frontend/js/src/forms/ConditionsModal.tsx b/frontend/js/src/forms/ConditionsModal.tsx new file mode 100644 index 00000000..d99573c2 --- /dev/null +++ b/frontend/js/src/forms/ConditionsModal.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { ProjectIconPills } from "./utils"; + +export default function ConditionsModal() { + return ( +
+
+
+
+ +

Privacy policy

+
+
Licensing
+

+ Any contributions you make to MusicBrainz will be released into the + Public Domain and/or licensed under a Creative Commons by-nc-sa + license. Furthermore, you give the MetaBrainz Foundation the right + to license this data for commercial use. +
+ Please read our{" "} + + {" "} + license page + {" "} + for more details. +

+
Privacy
+

+ MusicBrainz strongly believes in the privacy of its users. Any + personal information you choose to provide will not be sold or + shared with anyone else. +
+ Please read our{" "} + + privacy policy + {" "} + for more details. +

+
GDPR compliance
+

+ You may remove your personal information from our services anytime + by deleting your account. +
+ Please read our{" "} + + GDPR compliance statement + {" "} + for more details. +

+
+ +

+ Creating an account on MetaBrainz will give you access to all of our + projects, such as MusicBrainz, ListenBrainz, BookBrainz, and more. +
+ We do not automatically create accounts for these services when you + create a MetaBrainz account, but you will be just a few clicks away + from doing so. +

+ +
+
+
+ ); +} diff --git a/frontend/js/src/forms/LoginUser.tsx b/frontend/js/src/forms/LoginUser.tsx new file mode 100644 index 00000000..528ca912 --- /dev/null +++ b/frontend/js/src/forms/LoginUser.tsx @@ -0,0 +1,126 @@ +import { Formik } from "formik"; +import React, { JSX } from "react"; +import { createRoot } from "react-dom/client"; +import * as Yup from "yup"; +import { getPageProps } from "../utils"; +import { + AuthCardCheckboxInput, + AuthCardContainer, + AuthCardPasswordInput, + AuthCardTextInput, +} from "./utils"; + +type LoginUserProps = { + csrf_token: string; + initial_form_data: any; + initial_errors: any; +}; + +function LoginUser({ + csrf_token, + initial_form_data, + initial_errors, +}: LoginUserProps): JSX.Element { + return ( + +
+
+

Welcome back!

+ {}} + > + {({ errors }) => ( +
+ + Forgot username? + + } + type="text" + name="username" + id="username" + required + /> + + + Forgot password? + + } + name="password" + id="password" + required + /> + + + + + +
+
+ +
+ {errors.csrf_token && ( +
+ {errors.csrf_token} +
+ )} +
+ + + + )} +
+
+ Don't have an account? Sign up +
+
+
+
+ ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { csrf_token, initial_form_data, initial_errors } = reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render( + + ); +}); diff --git a/frontend/js/src/forms/LostPassword.tsx b/frontend/js/src/forms/LostPassword.tsx new file mode 100644 index 00000000..3116b3d2 --- /dev/null +++ b/frontend/js/src/forms/LostPassword.tsx @@ -0,0 +1,98 @@ +import { Formik } from "formik"; +import React, { JSX } from "react"; +import { createRoot } from "react-dom/client"; +import * as Yup from "yup"; +import { getPageProps } from "../utils"; +import { AuthCardContainer, AuthCardTextInput } from "./utils"; + +type LostPasswordProps = { + csrf_token: string; + initial_form_data: any; + initial_errors: any; +}; + +function LostPassword({ + csrf_token, + initial_form_data, + initial_errors, +}: LostPasswordProps): JSX.Element { + return ( + +
+
+

Forgot your password?

+ {}} + > + {({ errors }) => ( +
+ + + + +
+
+ +
+ {errors.csrf_token && ( +
+ {errors.csrf_token} +
+ )} +
+ + + )} +
+
+
+
+ ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { csrf_token, initial_form_data, initial_errors } = reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render( + + ); +}); diff --git a/frontend/js/src/forms/LostUsername.tsx b/frontend/js/src/forms/LostUsername.tsx new file mode 100644 index 00000000..b65f4b02 --- /dev/null +++ b/frontend/js/src/forms/LostUsername.tsx @@ -0,0 +1,89 @@ +import { Formik } from "formik"; +import React, { JSX } from "react"; +import { createRoot } from "react-dom/client"; +import * as Yup from "yup"; +import { getPageProps } from "../utils"; +import { AuthCardContainer, AuthCardTextInput } from "./utils"; + +type LostUsernameProps = { + csrf_token: string; + initial_form_data: any; + initial_errors: any; +}; + +function LostUsername({ + csrf_token, + initial_form_data, + initial_errors, +}: LostUsernameProps): JSX.Element { + return ( + +
+
+

Forgot your username?

+ {}} + > + {({ errors }) => ( +
+ + +
+
+ +
+ {errors.csrf_token && ( +
+ {errors.csrf_token} +
+ )} +
+ + + + )} +
+
+
+
+ ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { csrf_token, initial_form_data, initial_errors } = reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render( + + ); +}); diff --git a/frontend/js/src/forms/SupporterProfileEdit.tsx b/frontend/js/src/forms/ProfileEdit.tsx similarity index 62% rename from frontend/js/src/forms/SupporterProfileEdit.tsx rename to frontend/js/src/forms/ProfileEdit.tsx index b3ed8b53..7d916e50 100644 --- a/frontend/js/src/forms/SupporterProfileEdit.tsx +++ b/frontend/js/src/forms/ProfileEdit.tsx @@ -3,44 +3,46 @@ import React, { JSX } from "react"; import { createRoot } from "react-dom/client"; import * as Yup from "yup"; import { getPageProps } from "../utils"; -import { Dataset, DatasetsInput, TextInput } from "./utils"; +import { DatasetsInput, TextInput } from "./utils"; -type SupporterProfileEditProps = { +type ProfileEditProps = { datasets: Dataset[]; + is_supporter: boolean; is_commercial: boolean; csrf_token: string; initial_form_data: any; initial_errors: any; }; -function SupporterProfileEdit({ +function ProfileEdit({ datasets, is_commercial, + is_supporter, csrf_token, initial_form_data, initial_errors, -}: SupporterProfileEditProps): JSX.Element { +}: ProfileEditProps): JSX.Element { + const schema: any = { + email: Yup.string().email().required("Email address is required!"), + }; + if (is_supporter) { + schema.contact_name = Yup.string().required("Contact name is required!"); + } return ( <>

Your Profile

Edit contact information

- x.toString()) ?? [], - contact_name: initial_form_data.contact_name ?? "", - contact_email: initial_form_data.contact_email ?? "", + contact_name: initial_form_data?.contact_name ?? "", + email: initial_form_data?.email ?? "", csrf_token, }} initialErrors={initial_errors} initialTouched={initial_errors} - validationSchema={Yup.object({ - contact_name: Yup.string().required("Contact name is required!"), - contact_email: Yup.string() - .email() - .required("Email address is required!"), - })} + validationSchema={Yup.object(schema)} onSubmit={() => {}} > {({ errors }) => ( @@ -59,24 +61,35 @@ function SupporterProfileEdit({ )} - + {is_supporter && ( + + )}
- {!is_commercial && } + {is_supporter && !is_commercial && ( +
+
+ Datasets +
+
+ +
+
+ )}
@@ -93,10 +106,11 @@ function SupporterProfileEdit({ } document.addEventListener("DOMContentLoaded", () => { - const { domContainer, reactProps, globalProps } = getPageProps(); + const { domContainer, reactProps } = getPageProps(); const { datasets, is_commercial, + is_supporter, csrf_token, initial_form_data, initial_errors, @@ -104,8 +118,9 @@ document.addEventListener("DOMContentLoaded", () => { const renderRoot = createRoot(domContainer!); renderRoot.render( - +
+
+

Reset your password

+ {}} + > + {({ errors }) => ( +
+
+
+ +
+ {errors.csrf_token && ( +
+ {errors.csrf_token} +
+ )} +
+ + + + + + + + )} +
+
+
+ + ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { csrf_token, initial_errors } = reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render( + + ); +}); diff --git a/frontend/js/src/forms/SignupCommercial.tsx b/frontend/js/src/forms/SignupCommercial.tsx index 0c9e0ffc..c41bebc8 100644 --- a/frontend/js/src/forms/SignupCommercial.tsx +++ b/frontend/js/src/forms/SignupCommercial.tsx @@ -4,7 +4,13 @@ import { createRoot } from "react-dom/client"; import ReCAPTCHA from "react-google-recaptcha"; import * as Yup from "yup"; import { getPageProps } from "../utils"; -import { CheckboxInput, TextAreaInput, TextInput } from "./utils"; +import { + AuthCardContainer, + AuthCardPasswordInput, + AuthCardTextInput, + CheckboxInput, + TextAreaInput, +} from "./utils"; type AmountPledgedFieldProps = JSX.IntrinsicElements["input"] & FieldConfig & { @@ -18,11 +24,11 @@ function AmountPledgedField({ tier, ...props }: AmountPledgedFieldProps) { const [field, meta] = useField(props); return (
-
+ + ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { recaptcha_site_key, csrf_token, initial_form_data, initial_errors } = + reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render( + + ); +}); diff --git a/frontend/js/src/forms/UserProfileEdit.tsx b/frontend/js/src/forms/UserProfileEdit.tsx new file mode 100644 index 00000000..82c981f9 --- /dev/null +++ b/frontend/js/src/forms/UserProfileEdit.tsx @@ -0,0 +1,86 @@ +import { Formik } from "formik"; +import React, { JSX } from "react"; +import { createRoot } from "react-dom/client"; +import * as Yup from "yup"; +import { getPageProps } from "../utils"; +import { TextInput } from "./utils"; + +type UserProfileEditProps = { + csrf_token: string; + initial_form_data: any; + initial_errors: any; +}; + +function UserProfileEdit({ + csrf_token, + initial_form_data, + initial_errors, +}: UserProfileEditProps): JSX.Element { + return ( + <> +

Your Profile

+

Edit contact information

+ + {}} + > + {({ errors }) => ( +
+
+
+ +
+ {errors.csrf_token && ( +
{errors.csrf_token}
+ )} +
+ + + +
+
+ +
+
+ + )} +
+ + ); +} + +document.addEventListener("DOMContentLoaded", () => { + const { domContainer, reactProps } = getPageProps(); + const { csrf_token, initial_form_data, initial_errors } = reactProps; + + const renderRoot = createRoot(domContainer!); + renderRoot.render( + + ); +}); diff --git a/frontend/js/src/forms/utils.tsx b/frontend/js/src/forms/utils.tsx index 499717bd..d0da0dbc 100644 --- a/frontend/js/src/forms/utils.tsx +++ b/frontend/js/src/forms/utils.tsx @@ -8,8 +8,9 @@ export type TextInputProps = JSX.IntrinsicElements["input"] & export function TextInput({ label, children, ...props }: TextInputProps) { const [field, meta] = useField(props); + const hasError = meta.touched && meta.error; return ( -
+
@@ -17,11 +18,8 @@ export function TextInput({ label, children, ...props }: TextInputProps) {
{children} - {meta.touched && meta.error ? ( -
+ {hasError ? ( +
{meta.error}
) : null} @@ -40,22 +38,16 @@ export function TextAreaInput({ ...props }: TextAreaInputProps) { const [field, meta] = useField(props); + const hasError = meta.touched && meta.error; return ( -
-
+
+
+
+
+ + +
+
+

Moderation History

+
+
+ {% if model.moderation_logs %} +
+ + + + + + + + + + + {% for log in model.moderation_logs %} + + + + + + + {% endfor %} + +
DateActionModeratorReason
{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} + + {{ log.action }} + + {{ log.moderator.name }}{{ log.reason }}
+
+ {% else %} +

No moderation history found.

+ {% endif %} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/metabrainz/templates/admin/users/index.html b/metabrainz/templates/admin/users/index.html new file mode 100644 index 00000000..1d098248 --- /dev/null +++ b/metabrainz/templates/admin/users/index.html @@ -0,0 +1,28 @@ +{% extends "admin/model/list.html" %} + +{% block list_row %} + + {{ row.id }} + {{ row.name }} + + {{ row.get_email_any() }} + + {{ 'Confirmed' if row.is_email_confirmed() else 'Unconfirmed' }} + + + {{ row.member_since.strftime('%Y-%m-%d %H:%M:%S') }} + + + {{ 'Blocked' if row.is_blocked else 'Active' }} + + + + View Details + + +{% endblock %} + +{% block empty_list_message %} +

No users found.

+{% endblock %} diff --git a/metabrainz/templates/api/info.html b/metabrainz/templates/api/info.html index 5738ba0f..35e07cad 100644 --- a/metabrainz/templates/api/info.html +++ b/metabrainz/templates/api/info.html @@ -9,7 +9,7 @@

{{ _('MetaBrainz API') }}

{{ _('All endpoints require an access token which you can get from your - profile page.', profile_url=url_for('supporters.profile')) }} + profile page.', profile_url=url_for('index.profile')) }}

{{ _('MusicBrainz Live Data Feed') }}

diff --git a/metabrainz/templates/base.html b/metabrainz/templates/base.html index 0a8489a1..dd9eba0f 100644 --- a/metabrainz/templates/base.html +++ b/metabrainz/templates/base.html @@ -70,11 +70,11 @@ diff --git a/metabrainz/templates/email/supporter-commercial-welcome-email-address-verification.txt b/metabrainz/templates/email/supporter-commercial-welcome-email-address-verification.txt new file mode 100644 index 00000000..5dc6bcd9 --- /dev/null +++ b/metabrainz/templates/email/supporter-commercial-welcome-email-address-verification.txt @@ -0,0 +1,20 @@ +Dear {{ username }}, + +Thank you for signing up! + +Before we can review your application, we request you to verify your MetaBrainz account. Please +click on the link below to verify your email address: + +{{ verification_link }} + +If clicking the link above doesn't work, please copy and paste the URL in a new browser window instead. + +Once your email is verified, we will start reviewing your application. We will send you updates via email. +Please know that you may use our APIs and static data dumps for evaluation purposes while your application +is pending. You do not need any API keys to do this. + +This email was triggered by a request from the IP address [{{ ip }}]. + +Thanks for using MetaBrainz! + +-- The MetaBrainz Team diff --git a/metabrainz/templates/email/supporter-noncommercial-welcome-email-address-verification.txt b/metabrainz/templates/email/supporter-noncommercial-welcome-email-address-verification.txt new file mode 100644 index 00000000..0be9808f --- /dev/null +++ b/metabrainz/templates/email/supporter-noncommercial-welcome-email-address-verification.txt @@ -0,0 +1,17 @@ +Dear {{ username }}, + +Thank you for signing up! + +We request you to verify your MetaBrainz account. Please click on the link below to verify your email address: + +{{ verification_link }} + +If clicking the link above doesn't work, please copy and paste the URL in a new browser window instead. + +Once your email is verified, you can generate an access token for the MetaBrainz API on your profile page. + +This email was triggered by a request from the IP address [{{ ip }}]. + +Thanks for using MetaBrainz! + +-- The MetaBrainz Team diff --git a/metabrainz/templates/email/user-email-address-verification.txt b/metabrainz/templates/email/user-email-address-verification.txt new file mode 100644 index 00000000..38006a7c --- /dev/null +++ b/metabrainz/templates/email/user-email-address-verification.txt @@ -0,0 +1,15 @@ +Hello {{ username }}, + +This is a verification email for your MetaBrainz account. Please click +on the link below to verify your email address: + +{{ verification_link }} + +If clicking the link above doesn't work, please copy and paste the URL in a +new browser window instead. + +This email was triggered by a request from the IP address [{{ ip }}]. + +Thanks for using MetaBrainz! + +-- The MetaBrainz Team diff --git a/metabrainz/templates/email/user-forgot-username.txt b/metabrainz/templates/email/user-forgot-username.txt new file mode 100644 index 00000000..1516dcb3 --- /dev/null +++ b/metabrainz/templates/email/user-forgot-username.txt @@ -0,0 +1,13 @@ +Someone, probably you, asked to look up the username of the +MetaBrainz account associated with this email address. + +Your MetaBrainz username is: {{ username }} + +If you have also forgotten your password, use this username and your email address +to reset your password here - {{ forgot_password_link }} + +If you didn't initiate this request and feel that you've received this email in +error, don't worry, you don't need to take any further action and can safely +disregard this email. + +-- The MetaBrainz Team diff --git a/metabrainz/templates/email/user-password-reset.txt b/metabrainz/templates/email/user-password-reset.txt new file mode 100644 index 00000000..94d37d81 --- /dev/null +++ b/metabrainz/templates/email/user-password-reset.txt @@ -0,0 +1,17 @@ +Someone, probably you, asked that your MetaBrainz password be reset. + +To reset your password, click the link below: + +{{ reset_password_link }} + +If clicking the link above doesn't work, please copy and paste the URL in a +new browser window instead. + +If you didn't initiate this request and feel that you've received this email in +error, don't worry, you don't need to take any further action and can safely +disregard this email. + +If you still have problems logging in, please drop us a line - see +{{ contact_url }} for details. + +-- The MetaBrainz Team diff --git a/metabrainz/templates/supporters/profile-edit.html b/metabrainz/templates/index/profile-edit.html similarity index 65% rename from metabrainz/templates/supporters/profile-edit.html rename to metabrainz/templates/index/profile-edit.html index 33b8d69a..8e84f39e 100644 --- a/metabrainz/templates/supporters/profile-edit.html +++ b/metabrainz/templates/index/profile-edit.html @@ -4,5 +4,5 @@ {% block scripts %} {{ super() }} - + {% endblock %} diff --git a/metabrainz/templates/index/profile.html b/metabrainz/templates/index/profile.html new file mode 100644 index 00000000..0dc6cd24 --- /dev/null +++ b/metabrainz/templates/index/profile.html @@ -0,0 +1,8 @@ +{% extends 'base-react.html' %} + +{% block title %}{{ _('Your Profile') }} - MetaBrainz Foundation{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %} diff --git a/metabrainz/templates/navbar.html b/metabrainz/templates/navbar.html index 045bc4cd..cdc37af0 100644 --- a/metabrainz/templates/navbar.html +++ b/metabrainz/templates/navbar.html @@ -57,16 +57,16 @@