diff --git a/app/controllers/api/nextv1/publishers_controller.rb b/app/controllers/api/nextv1/publishers_controller.rb index cd546f4948..32d84d4bc0 100644 --- a/app/controllers/api/nextv1/publishers_controller.rb +++ b/app/controllers/api/nextv1/publishers_controller.rb @@ -5,8 +5,21 @@ class Api::Nextv1::PublishersController < Api::Nextv1::BaseController def me publisher_hash = JSON.parse(current_publisher.to_json) - publisher_hash["two_factor_enabled"] = two_factor_enabled?(current_publisher) - render(json: publisher_hash.to_json, status: 200) + response_data = { + **publisher_hash, + two_factor_enabled: two_factor_enabled?(current_publisher) + } + render(json: response_data.to_json, status: 200) + end + + def security + response_data = { + u2f_enabled: u2f_enabled?(current_publisher), + totp_enabled: totp_enabled?(current_publisher), + u2f_registrations: current_publisher.u2f_registrations + } + + render(json: response_data.to_json, status: 200) end def update diff --git a/app/controllers/api/nextv1/totp_registrations_controller.rb b/app/controllers/api/nextv1/totp_registrations_controller.rb index c31e142493..78a18bc2be 100644 --- a/app/controllers/api/nextv1/totp_registrations_controller.rb +++ b/app/controllers/api/nextv1/totp_registrations_controller.rb @@ -1,5 +1,8 @@ class Api::Nextv1::TotpRegistrationsController < Api::Nextv1::BaseController include QrCodeHelper + include TwoFactorRegistration + include PendingActions + def new @totp_registration = TotpRegistration.new secret: ROTP::Base32.random_base32 @provisioning_url = @totp_registration.totp.provisioning_uri(current_publisher.email) @@ -11,4 +14,46 @@ def new render(json: response_data.to_json, status: 200) end + + class AddTOTP < StepUpAction + call do |publisher_id, password, totp_registration_params| + current_publisher = Publisher.find(publisher_id) + totp_registration = TotpRegistration.new totp_registration_params + + if totp_registration.totp.verify(password, drift_ahead: 60, drift_behind: 60, at: Time.now - 30) + current_publisher.totp_registration.destroy! if current_publisher.totp_registration.present? + totp_registration.publisher = current_publisher + totp_registration.save! + + logout_everybody_else!(current_publisher) + + render(json: {}, status: 200) + else + render(json: {errors: totp_registration.errors}, status: 400) + end + end + end + + def create + AddTOTP.new(current_publisher.id, params[:totp_password], totp_registration_params.to_h).step_up! self + end + + class RemoveTOTP < StepUpAction + call do |publisher_id| + current_publisher = Publisher.find(publisher_id) + current_publisher.totp_registration.destroy! if current_publisher.totp_registration.present? + end + end + + def destroy + RemoveTOTP.new(current_publisher.id).step_up! self + + render(json: {}, status: 200) + end + + private + + def totp_registration_params + params.require(:totp_registration).permit(:secret) + end end diff --git a/app/controllers/api/nextv1/u2f_registrations_controller.rb b/app/controllers/api/nextv1/u2f_registrations_controller.rb new file mode 100644 index 0000000000..6a2e323cc6 --- /dev/null +++ b/app/controllers/api/nextv1/u2f_registrations_controller.rb @@ -0,0 +1,64 @@ +class Api::Nextv1::U2fRegistrationsController < Api::Nextv1::BaseController + include Logout + include TwoFactorRegistration + + include PendingActions + + before_action :authenticate_publisher! + + def new + @u2f_registration = U2fRegistration.new + publisher = current_publisher + + @webauthn_options = WebAuthn::Credential.options_for_create( + user: {id: publisher.id, name: publisher.email}, + exclude: publisher.u2f_registrations.map { |c| c.key_handle }.compact + ) + + session[:creation_challenge] = @webauthn_options.challenge + + render(json: @webauthn_options.to_json, status: 200) + end + + class AddU2F < StepUpAction + call do |publisher_id, webauthn_response, name, challenge| + current_publisher = Publisher.find(publisher_id) + result = TwoFactorAuth::WebauthnRegistrationService.build.call(publisher: current_publisher, + webauthn_response: webauthn_response, + name: name, + challenge: challenge) + + case result + when BSuccess + logout_everybody_else!(current_publisher) + render(json: {}, status: 200) + when BFailure + render(json: {errors: ["Webauthn registration failed"]}, status: 400) && return + else + raise result + end + end + end + + def create + AddU2F.new(current_publisher.id, + params[:webauthn_response], + params.require(:u2f_registration).permit(:name)[:name], + session[:creation_challenge]).step_up! self + end + + class RemoveU2F < StepUpAction + call do |publisher_id, u2f_id| + current_publisher = Publisher.find(publisher_id) + + u2f_registration = current_publisher.u2f_registrations.find(u2f_id) + u2f_registration.destroy + end + end + + def destroy + RemoveU2F.new(current_publisher.id, params[:id]).step_up! self + + render(json: {}, status: 200) + end +end diff --git a/app/services/pending_actions.rb b/app/services/pending_actions.rb index a8adfd374b..6eb04aea68 100644 --- a/app/services/pending_actions.rb +++ b/app/services/pending_actions.rb @@ -121,7 +121,14 @@ def execute! context def step_up! context if context.two_factor_enabled?(current_publisher) save! context - context.redirect_to context.two_factor_authentications_path + if context.class.name.include?("Nextv1") + execute! context + # context.render(json: { + # error: '2fa_required' + # }, status: 200) + else + context.redirect_to context.two_factor_authentications_path + end else execute! context end diff --git a/config/routes.rb b/config/routes.rb index aa37217df2..3c6710b096 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -167,9 +167,18 @@ namespace :nextv1, defaults: {format: :json} do resources :publishers, only: [:update, :destroy] get "publishers/me", to: "publishers#me" + get "publishers/security", to: "publishers#security" namespace :totp_registrations do get :new + post :create + delete :destroy + end + + namespace :u2f_registrations do + get :new + post :create + delete :destroy end end diff --git a/nextjs/package-lock.json b/nextjs/package-lock.json index dfe05c1c29..e0f849561f 100644 --- a/nextjs/package-lock.json +++ b/nextjs/package-lock.json @@ -9,9 +9,11 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { - "@brave/leo": "github:brave/leo#86be9379ea55a7e32b56beee05a4e57caa1c0da2", + "@brave/leo": "github:brave/leo#80f2230bcfb7d2a87e7025a4e26c502d900fd938", + "@github/webauthn-json": "^2.1.1", "axios": "^1.5.0", "clsx": "^2.0.0", + "moment": "^2.29.4", "next": "^13.4.12", "next-intl": "^3.0.0-beta.9", "react": "^18.2.0", @@ -2112,11 +2114,12 @@ }, "node_modules/@brave/leo": { "version": "0.0.1", - "resolved": "git+ssh://git@github.com/brave/leo.git#86be9379ea55a7e32b56beee05a4e57caa1c0da2", - "integrity": "sha512-dZwR61uFexQOiQNytbr+VeuFNghY4KjPsOPPU8LfraPkJq8SEtgGJAVvlW1pyDsFUyPvMOpjBHHI1z5pSNAWYw==", + "resolved": "git+ssh://git@github.com/brave/leo.git#80f2230bcfb7d2a87e7025a4e26c502d900fd938", + "integrity": "sha512-n1Qbr5n+oQKpqaU5hVAeov4HVF6FPYvUVg1Dk94NuCU+qcMtYyUckwlLE7Yx+J+olVoqmSEfkGw9vUYYEvDFqw==", "license": "MIT", "dependencies": { "@ctrl/tinycolor": "3.5.1", + "@floating-ui/dom": "1.4.4", "@tsconfig/svelte": "3.0.0", "lodash.camelcase": "4.3.0", "lodash.merge": "4.6.2", @@ -2748,6 +2751,27 @@ "npm": ">=6.14.13" } }, + "node_modules/@floating-ui/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz", + "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==", + "dependencies": { + "@floating-ui/utils": "^0.1.3" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.4.4.tgz", + "integrity": "sha512-21hhDEPOiWkGp0Ys4Wi6Neriah7HweToKra626CIK712B5m9qkdz54OP9gVldUg+URnBTpv/j/bi/skmGdstXQ==", + "dependencies": { + "@floating-ui/core": "^1.3.1" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.4.tgz", + "integrity": "sha512-qprfWkn82Iw821mcKofJ5Pk9wgioHicxcQMxx+5zt5GSKoqdWvgG5AxVmpmUUjzTLPVSH5auBrhI93Deayn/DA==" + }, "node_modules/@formatjs/ecma402-abstract": { "version": "1.17.2", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.17.2.tgz", @@ -2834,6 +2858,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@github/webauthn-json": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@github/webauthn-json/-/webauthn-json-2.1.1.tgz", + "integrity": "sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==", + "bin": { + "webauthn-json": "dist/bin/main.js" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz", @@ -11346,6 +11378,14 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -17400,11 +17440,12 @@ "dev": true }, "@brave/leo": { - "version": "git+ssh://git@github.com/brave/leo.git#86be9379ea55a7e32b56beee05a4e57caa1c0da2", - "integrity": "sha512-dZwR61uFexQOiQNytbr+VeuFNghY4KjPsOPPU8LfraPkJq8SEtgGJAVvlW1pyDsFUyPvMOpjBHHI1z5pSNAWYw==", - "from": "@brave/leo@github:brave/leo#86be9379ea55a7e32b56beee05a4e57caa1c0da2", + "version": "git+ssh://git@github.com/brave/leo.git#80f2230bcfb7d2a87e7025a4e26c502d900fd938", + "integrity": "sha512-n1Qbr5n+oQKpqaU5hVAeov4HVF6FPYvUVg1Dk94NuCU+qcMtYyUckwlLE7Yx+J+olVoqmSEfkGw9vUYYEvDFqw==", + "from": "@brave/leo@github:brave/leo#80f2230bcfb7d2a87e7025a4e26c502d900fd938", "requires": { "@ctrl/tinycolor": "3.5.1", + "@floating-ui/dom": "1.4.4", "@tsconfig/svelte": "3.0.0", "lodash.camelcase": "4.3.0", "lodash.merge": "4.6.2", @@ -17810,6 +17851,27 @@ "integrity": "sha512-38DT60rumHfBYynif3lmtxMqMqmsOQIxQgEuPZxCk2yUYN0eqWpTACgxi0VpidvsJB8CRxCpvP7B3anK85FjtQ==", "dev": true }, + "@floating-ui/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz", + "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==", + "requires": { + "@floating-ui/utils": "^0.1.3" + } + }, + "@floating-ui/dom": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.4.4.tgz", + "integrity": "sha512-21hhDEPOiWkGp0Ys4Wi6Neriah7HweToKra626CIK712B5m9qkdz54OP9gVldUg+URnBTpv/j/bi/skmGdstXQ==", + "requires": { + "@floating-ui/core": "^1.3.1" + } + }, + "@floating-ui/utils": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.4.tgz", + "integrity": "sha512-qprfWkn82Iw821mcKofJ5Pk9wgioHicxcQMxx+5zt5GSKoqdWvgG5AxVmpmUUjzTLPVSH5auBrhI93Deayn/DA==" + }, "@formatjs/ecma402-abstract": { "version": "1.17.2", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.17.2.tgz", @@ -17902,6 +17964,11 @@ "tslib": "^2.4.0" } }, + "@github/webauthn-json": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@github/webauthn-json/-/webauthn-json-2.1.1.tgz", + "integrity": "sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==" + }, "@humanwhocodes/config-array": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz", @@ -24239,6 +24306,11 @@ "minimist": "^1.2.6" } }, + "moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" + }, "mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", diff --git a/nextjs/package.json b/nextjs/package.json index a6e0537408..4b5f802894 100644 --- a/nextjs/package.json +++ b/nextjs/package.json @@ -18,9 +18,11 @@ "typecheck": "tsc --noEmit --incremental false" }, "dependencies": { - "@brave/leo": "github:brave/leo#86be9379ea55a7e32b56beee05a4e57caa1c0da2", + "@brave/leo": "github:brave/leo#80f2230bcfb7d2a87e7025a4e26c502d900fd938", + "@github/webauthn-json": "^2.1.1", "axios": "^1.5.0", "clsx": "^2.0.0", + "moment": "^2.29.4", "next": "^13.4.12", "next-intl": "^3.0.0-beta.9", "react": "^18.2.0", diff --git a/nextjs/src/app/[locale]/publishers/loading.tsx b/nextjs/src/app/[locale]/publishers/loading.tsx new file mode 100644 index 0000000000..a0e3bf87ce --- /dev/null +++ b/nextjs/src/app/[locale]/publishers/loading.tsx @@ -0,0 +1,29 @@ +export default function Loading() { + // You can add any UI inside Loading, including a Skeleton. + return ( +
+
+ + + + +
+
+ ); +} diff --git a/nextjs/src/app/[locale]/publishers/security/page.tsx b/nextjs/src/app/[locale]/publishers/security/page.tsx index dcf0a652a9..f6ea3ee716 100644 --- a/nextjs/src/app/[locale]/publishers/security/page.tsx +++ b/nextjs/src/app/[locale]/publishers/security/page.tsx @@ -2,15 +2,16 @@ import Alert from '@brave/leo/react/alert'; import Button from '@brave/leo/react/button'; +import Dialog from '@brave/leo/react/dialog'; import Icon from '@brave/leo/react/icon'; import clsx from 'clsx'; +import moment from 'moment'; import Head from 'next/head'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; -import { useContext } from 'react'; -import * as React from 'react'; +import { useEffect, useState } from 'react'; -import UserContext from '@/lib/context/UserContext'; +import { apiRequest } from '@/lib/api'; import Card from '@/components/Card'; @@ -18,11 +19,64 @@ import PhoneOutline from '~/images/phone_outline.svg'; import USBOutline from '~/images/usb_outline.svg'; export default function SecurityPage() { - const { user } = useContext(UserContext); - const { two_factor_enabled } = user; + const [modal, setModal] = useState({ isOpen: false, id: null }); + const [isLoading, setIsLoading] = useState(true); + const [security, setSecurity] = useState({ + u2f_registrations: [], + u2f_enabled: false, + totp_enabled: false, + }); + const two_factor_enabled = security.u2f_enabled || security.totp_enabled; + const { u2f_registrations, totp_enabled } = security; const t = useTranslations(); - return ( + useEffect(() => { + fetchsecurity(); + }, []); + + async function fetchsecurity() { + const res = await apiRequest(`/publishers/security`); + setIsLoading(false); + setSecurity(res); + } + + async function removeSecurityKey() { + const id = modal.id; + + const res = await apiRequest(`u2f_registrations/destroy`, 'DELETE', { id }); + + if (!res.errors) { + const newRegistrations = u2f_registrations.filter((k) => k.id !== id); + setModal({ isOpen: false, id: null }); + setSecurity({ + ...security, + u2f_registrations: newRegistrations, + u2f_enabled: !!newRegistrations.length, + }); + } + } + + async function removeTotp() { + const res = await apiRequest(`totp_registrations/destroy`, 'DELETE'); + + if (!res.errors) { + setModal({ isOpen: false, id: null }); + setSecurity({ + ...security, + totp_enabled: false, + }); + } + } + + function getStatusText() { + if (modal.id === 'totp') { + return security.u2f_enabled ? 'Hardware Security Key' : 'None'; + } else { + return security.totp_enabled ? 'Authenticator app on your phone' : 'None'; + } + } + + return isLoading ? null : (
{t('NavDropdown.security')} @@ -34,14 +88,18 @@ export default function SecurityPage() {

{t('security.index.heading')}

{t('security.index.intro')}
-
+
{two_factor_enabled && } + {!two_factor_enabled && } {two_factor_enabled ? t('security.index.enabled_yes') : t('security.index.enabled_no')} @@ -55,14 +113,33 @@ export default function SecurityPage() {

{t('security.index.totp.heading')}

{t('security.index.totp.intro')}
- - {t('security.index.totp.disabled_without_fallback_html')} - + {!totp_enabled && ( + + {t('security.index.totp.disabled_without_fallback_html')} + + )} + {totp_enabled && ( +
+ + {t('security.index.totp.enabled')} + + {' | '} + setModal({ isOpen: true, id: 'totp' })} + > + {t('shared.remove')} + +
+ )}
- @@ -75,11 +152,34 @@ export default function SecurityPage() {

{t('security.index.u2f.heading')}

{t('security.index.u2f.intro')}
-
- Nano!: registered on June 27, 2023 | Remove -
+ {!!u2f_registrations.length && ( +
+ {security.u2f_registrations.map((item) => { + return ( +
+ + {`${item.name} `} + + + {`registered on `} + {moment(item.created_at).format('MMMM D, YYYY')} + + {' | '} + + setModal({ isOpen: true, id: item.id }) + } + > + {t('shared.remove')} + +
+ ); + })} +
+ )}
-
+
+ + +
+ {modal.id === 'totp' + ? 'Disable Authenticator App?' + : 'Remove Security Key?'} +
+
+ {t('u2f_registrations.u2f_registration.confirm_disable.intro')} +
+
[{getStatusText()}]
+
+ {modal.id === 'totp' + ? 'Authenticator app provides a good fallback method to log in to your account securely in the case that you lose the hardware security key.' + : 'Removing this security key will effectively turn off the two-factor authentication for your account.'} +
+
+ {modal.id === 'totp' + ? 'Are you sure you want to disable authenticator app?' + : 'Are you sure you want to remove this security key?'} +
+
+ + +
+
); diff --git a/nextjs/src/app/[locale]/publishers/settings/page.tsx b/nextjs/src/app/[locale]/publishers/settings/page.tsx index 15ce2018a9..829cf650cd 100644 --- a/nextjs/src/app/[locale]/publishers/settings/page.tsx +++ b/nextjs/src/app/[locale]/publishers/settings/page.tsx @@ -27,19 +27,15 @@ export default function SettingsPage() { const t = useTranslations(); function updateAccountSettings(newSettings?) { - apiRequest( - `publishers/${user.id}`, - { - publisher: pick( - newSettings || settings, - 'email', - 'name', - 'subscribed_to_marketing_emails', - 'thirty_day_login', - ), - }, - 'PUT', - ); + apiRequest(`publishers/${user.id}`, 'PUT', { + publisher: pick( + newSettings || settings, + 'email', + 'name', + 'subscribed_to_marketing_emails', + 'thirty_day_login', + ), + }); updateUser(settings); } diff --git a/nextjs/src/app/[locale]/publishers/totp_registrations/new/page.tsx b/nextjs/src/app/[locale]/publishers/totp_registrations/new/page.tsx index 3bb517b0ac..15c57d4911 100644 --- a/nextjs/src/app/[locale]/publishers/totp_registrations/new/page.tsx +++ b/nextjs/src/app/[locale]/publishers/totp_registrations/new/page.tsx @@ -5,20 +5,28 @@ import Button from '@brave/leo/react/button'; import Input from '@brave/leo/react/input'; import Head from 'next/head'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; -import { useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { apiRequest } from '@/lib/api'; +import UserContext from '@/lib/context/UserContext'; import Card from '@/components/Card'; import NextImage from '@/components/NextImage'; export default function TOTPNewPage() { const t = useTranslations(); + const { push } = useRouter(); + const { user } = useContext(UserContext); + const [hasErrors, setHasErrors] = useState(false); + const [code, setCode] = useState(''); const [totp, setTotp] = useState({ registration: { secret: '' }, qr_code_svg: '', }); + const formattedCode = + totp?.registration?.secret.match(/.{4}/g)?.join(' ') || ''; async function fetchTotp() { const data = await apiRequest('totp_registrations/new'); @@ -28,6 +36,24 @@ export default function TOTPNewPage() { fetchTotp(); }, []); + function handleInputChange(e) { + setCode(e.detail.value); + } + + async function handleSubmit() { + const response = await apiRequest('totp_registrations/create', 'POST', { + totp_password: code, + totp_registration: { + secret: totp.registration.secret, + }, + }); + if (response.errors) { + setHasErrors(true); + } else { + push('/publishers/security'); + } + } + return (
@@ -36,6 +62,11 @@ export default function TOTPNewPage() {
+ {user.two_factor_enabled && ( + + {t('totp_registrations.new.warning')} + + )}

{t('totp_registrations.new.heading')}

1. {t('totp_registrations.new.step_1')}
@@ -44,9 +75,7 @@ export default function TOTPNewPage() {
{t('totp_registrations.new.step_2_alt')}
- {` ${totp?.registration?.secret - .match(/.{4}/g) - ?.join(' ')}`} + {` ${formattedCode}`}
@@ -65,12 +94,21 @@ export default function TOTPNewPage() {
3. {t('totp_registrations.new.step_3')}
- + +
{t('shared.invalid_totp')}
+
- +
diff --git a/nextjs/src/app/[locale]/publishers/u2f_registrations/new/page.tsx b/nextjs/src/app/[locale]/publishers/u2f_registrations/new/page.tsx index fe027a417c..928feb507d 100644 --- a/nextjs/src/app/[locale]/publishers/u2f_registrations/new/page.tsx +++ b/nextjs/src/app/[locale]/publishers/u2f_registrations/new/page.tsx @@ -1,15 +1,83 @@ 'use client'; import Button from '@brave/leo/react/button'; +import Input from '@brave/leo/react/input'; +import { + create, + parseCreationOptionsFromJSON, +} from '@github/webauthn-json/browser-ponyfill'; import Head from 'next/head'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; -import * as React from 'react'; +import { useEffect, useState } from 'react'; + +import { apiRequest } from '@/lib/api'; import Card from '@/components/Card'; export default function U2fRegistrations() { const t = useTranslations(); + const { push } = useRouter(); + const [name, setName] = useState(''); + const [webauthn, setWebauthn] = useState(); + const [isWaitingForKey, setIsWaitingForKey] = useState(false); + + async function fetchWebAuthResponse() { + const data = await apiRequest('u2f_registrations/new'); + setWebauthn(data); + } + + async function register({ user, challenge, excludeCredentials }) { + const body = parseCreationOptionsFromJSON({ + publicKey: { + challenge: challenge, + rp: { name: '' }, + user: { + id: user.id, + name: user.name, + displayName: user.displayName, + }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + excludeCredentials: excludeCredentials.map((x) => ({ + id: x.id, + type: 'public-key', + })), + authenticatorSelection: { userVerification: 'discouraged' }, + extensions: { + credProps: true, + }, + }, + }); + return await create(body); + } + + useEffect(() => { + fetchWebAuthResponse(); + }, []); + + function handleInputChange(e) { + setName(e.detail.value); + } + + async function handleSubmit() { + setIsWaitingForKey(true); + + const webauthn_response = await register(webauthn).finally(() => + setIsWaitingForKey(false), + ); + + const response = await apiRequest('u2f_registrations/create', 'POST', { + u2f_registration: { + name, + }, + webauthn_response: JSON.stringify(webauthn_response), + }); + + if (!response.errors) { + push('/publishers/security'); + } + } return (
@@ -19,13 +87,22 @@ export default function U2fRegistrations() {

{t('u2f_registrations.new.heading')}

-
INPUT GOES HERE
+
+ + {t('activerecord.attributes.u2f_registration.name')} + +
-
+
- +
-
+
+ {isWaitingForKey && ( +
+

{t('u2f_registrations.new.waiting_heading')}

+
{t('u2f_registrations.new.waiting_description')}
+
+ )}
diff --git a/nextjs/src/lib/api.ts b/nextjs/src/lib/api.ts index 6556a54ea6..6eb557fd28 100644 --- a/nextjs/src/lib/api.ts +++ b/nextjs/src/lib/api.ts @@ -2,8 +2,8 @@ import axios from 'axios'; export async function apiRequest( path: string, - data?: unknown, method: string = 'GET', + data?: unknown, apiVersion: string = 'v1', ) { try { diff --git a/nextjs/src/lib/propTypes.ts b/nextjs/src/lib/propTypes.ts index 7cfd7481ad..9078058228 100644 --- a/nextjs/src/lib/propTypes.ts +++ b/nextjs/src/lib/propTypes.ts @@ -12,4 +12,5 @@ export type UserType = { thirty_day_login: boolean; subscribed_to_marketing_emails: boolean; two_factor_enabled: boolean; + u2f_registrations: Array<{ name: string; created_at: string; id: string }>; }; diff --git a/nextjs/src/messages/en.json b/nextjs/src/messages/en.json index 75fec8edd0..5bb6c37cd6 100644 --- a/nextjs/src/messages/en.json +++ b/nextjs/src/messages/en.json @@ -1,4 +1,62 @@ { + "shared": { + "add_channel": "Add Channel", + "add_promo_code": "Add Promo Code", + "app_title": "Brave Creators", + "app_description": "Do you run a website or channels on YouTube or Twitch? If you’re interested in monetizing your content, verify your site and unlock your contributions!", + "cancel": "Cancel", + "channel_created": "Channel created. Your verified status will be displayed in the Brave browser within the next 24-48 hours.", + "channel_contested": "Channel will be transferred from current owner in %{time_until_transfer}.", + "channel_could_not_be_contested": "Channel could not be contested. If you think this is in error, please contact us at https://community.brave.com/", + "channel_transfer_rejected": "Channel transfer rejected.", + "time_until_transfer_fallback": "a short time", + "channel_contested_by": "Channel has been verified by another publisher and will be transferred to their account in %{time_until_transfer} unless you reject.", + "channel_not_found": "Channel not found", + "channel_quota_exceeded": "We're so popular we exceeded our daily quota of YouTube registrations. Please try again tomorrow.", + "channel_removed": "Channel removed.", + "continue": "Continue", + "date_format": "%b %d, %Y", + "dashboard": "Dashboard", + "referrals": "Referrals", + "payments": "Payments", + "download": "Download", + "existing_account": "Already have an account?", + "error": "Oh no! Something went wrong, please contact support.", + "get_started": "Get Started", + "instant_donation": "Contribution Banner", + "invalid_totp": "Invalid 6-digit code. Please try again.", + "log_in": "Log In", + "remove": "Remove", + "terms_of_service": "Terms of Service", + "lost_2fa": "Lost 2fa?", + "faqs": "FAQs", + "ok": "OK", + "oh_no": "Oh no!", + "choose_different_verification_method": "Choose Different Verification Method", + "support_note_html": "If you have any questions, please contact the Brave Creators team at https://community.brave.com/", + "warning_note_html": "This email contains a private access link. Anyone with access to it can gain access to your private publisher information. Ensure you trust the recipient before forwarding or sharing this email.

If you have any questions, please contact the Brave Creators team at https://community.brave.com/" + }, + "activerecord": { + "shared": { + "errors": "There were errors saving your request:" + }, + "attributes": { + "uphold_connection": { + "send_emails": "Receive emails notifying me if I can't receive payments." + }, + "publisher": { + "marketing_emails": "By checking here, I consent to be informed of new features and promotions via email.", + "name": "Your Name" + }, + "site_channel_details": { + "brave_publisher_id": "Website Domain", + "brave_publisher_id_unnormalized": "Website Domain" + }, + "u2f_registration": { + "name": "Name Security Key" + } + } + }, "NavDropdown": { "faqs": "FAQs", "help": "Help", @@ -97,6 +155,16 @@ "setup": "Set Up 2FA", "skip": "Skip for now", "subheading": "by enabling two-factor authentication" + }, + "confirm_disable": { + "header": "Disable Authenticator App?", + "intro": "Your remaining two-factor authentication method:", + "none": "None", + "no_totp_warning": "Authenticator app provides a good fallback method to log\nin to your account securely in the case that you lose the\nhardware security key.\n", + "no_2fa_warning_html": "Disabling authenticator app will effectively\nturn off the two-factor authentication\nfor your account.\n", + "final_confirmation": "Are you sure you want to disable authenticator app?\n", + "deny": "Do Not Disable", + "confirm": "Disable it for now" } }, "totp_registrations": { @@ -126,7 +194,7 @@ "timeout": "There was an unexpected timeout waiting for your security key to respond to\nthe registration request. Please re-attempt registration and activate\nthe security key when it is blinking. (TIMEOUT)\n" }, "u2f-unavailable": "Your browser doesn't look like it supports U2F, the two factor auth\nplatform supported by Brave. Please use the latest version of Brave, Chrome\nor Opera to register your U2F-compatible device, for example a YubiKey.\n", - "waiting_description": "Insert the new hardware key into your computer and press the button when\nit's blinking.\n", + "waiting_description": "Insert the new hardware key into your computer and press the button when it's blinking.", "waiting_heading": "Waiting for your security key..." }, "u2f_registration": { @@ -143,4 +211,4 @@ "name_default": "Anonymous Key" } } -} +} \ No newline at end of file diff --git a/nextjs/src/messages/ja.json b/nextjs/src/messages/ja.json index 807ffae865..052f22c869 100644 --- a/nextjs/src/messages/ja.json +++ b/nextjs/src/messages/ja.json @@ -46,6 +46,43 @@ "alert": "アカウント設定が更新されました" } }, + "activerecord": { + "shared": { + "errors": "リクエストの保存中にエラーが発生しました:" + }, + "attributes": { + "publisher": { + "visible": "はい。Braveのマーケティングチャンネルにサイトを掲載して、認知を広めます", + "name": "氏名" + }, + "site_channel_details": { + "brave_publisher_id": "Webサイトドメイン", + "brave_publisher_id_unnormalized": "Webサイトドメイン" + }, + "u2f_registration": { + "name": "セキュリティーキーの名前" + } + }, + "errors": { + "models": { + "invoice": { + "attributes": { + "date": "パートナーごとに一意である必要があります" + } + }, + "site_channel_details": { + "attributes": { + "brave_publisher_id": { + "api_error_cant_normalize": "APIエラーのために正規化できません", + "exclusion_list_error": "Brave Publisherの除外リストに含まれていました。 さらにサポートが必要な場合は、https://community.brave.com/c/japanese-support/148にお問い合わせください。", + "taken": "入力したドメインは既に認証されており、別のアカウントに追加されています。 さらにサポートが必要な場合は、https://community.brave.com/c/japanese-support/148にお問い合わせください。", + "invalid_uri": "無効なドメインURI" + } + } + } + } + } + }, "security": { "index": { "enabled_no": "無効", @@ -99,17 +136,51 @@ "subheading": "2要素認証の設定をしてください" } }, + "shared": { + "add_channel": "チャンネルを追加", + "app_title": "Brave クリエイター", + "app_description": "Webサイトや、YoutubeやTwitchにチャンネルをお持ちですか? 認証を行うと閲覧者からの支援を受け収益化することができます。", + "cancel": "キャンセル", + "channel_created": "チャンネルを作成しました。24-48時間以内に認証ステータスが表示されます。", + "channel_contested": "チャンネルは %{time_until_transfer}以内に現在の所有者から移管されます", + "channel_could_not_be_contested": "チャンネルに異議を申し立てることができませんでした。 これが誤っていると思われる場合は、support + publishers @ basicattentiontoken.org までメールでお問い合わせください", + "channel_transfer_rejected": "チャンネル移管が却下されました。", + "time_until_transfer_fallback": "あと少し", + "channel_contested_by": "チャンネルは別のサイト運営者によって検証されており、却下しない限り、%{time_until_transfer}でそのアカウントに移行されます。", + "channel_not_found": "チャンネルが見つかりませんでした", + "channel_removed": "チャンネルが削除されました", + "continue": "続ける", + "dashboard": "ダッシュボード", + "referrals": "リファラル", + "payments": "支払い", + "download": "ダウンロード", + "existing_account": "既にアカウントをお持ちですか?", + "error": "問題が発生しました。サポートにお問い合わせください。", + "get_started": "さあ始めましょう", + "instant_donation": "チップ受付用バナー", + "invalid_totp": "6桁のコードが誤っています。もう一度試してください。", + "log_in": "ログイン", + "remove": "削除", + "terms_of_service": "利用規約", + "lost_2fa": "二段階認証に問題がありますか?", + "faqs": "FAQ", + "ok": "OK", + "oh_no": "おや?", + "choose_different_verification_method": "別の認証方法を選択する", + "support_note_html": "ご質問がある場合は、https://community.brave.com/c/japanese-support/148のBrave Creatorsチームにお問い合わせください。", + "warning_note_html": "このメールにはプライベートログインが含まれています。 このリンクを第三者に渡してしまうと誰でも管理画面がアクセスになります。このメールを転送または共有する際は受信者が信頼できるかご確認ください。

ご不明な点がございましたら、https://community.brave.com/c/japanese-support/148のBrave Creatorsチームにお問い合わせください。" + }, "totp_registrations": { "new": { "cancel": "キャンセル", "heading": "Authenticatorアプリのセットアップ", - "warning": "再設定すると、既存の認証コードデバイスが無効になります", + "password_prompt": "6桁のコード", "step_1": "スマートフォンに認証アプリをインストールします", "step_2": "アプリで以下のQRコードをスキャンします", "step_2_alt": "QRコードをスキャンできない場合は、次のコードを入力してください:", "step_3": "スキャンが完了したら、アプリから6桁のコードを入力します。", - "password_prompt": "6桁のコード", - "submit_value": "完了" + "submit_value": "完了", + "warning": "再設定すると、既存の認証コードデバイスが無効になります" } }, "u2f_registrations": { diff --git a/nextjs/src/styles/globals.css b/nextjs/src/styles/globals.css index d1b03c1f7a..92d3261b3b 100644 --- a/nextjs/src/styles/globals.css +++ b/nextjs/src/styles/globals.css @@ -68,6 +68,17 @@ hr { border-top: 1px solid rgb(30 32 41 / 10%); } +[slot='errors'] { + display: flex; + flex-direction: row; + gap: var(--leo-spacing-m); + align-items: center; + + margin-top: var(--leo-spacing-s); + + color: var(--leo-color-systemfeedback-error-icon); +} + .main { @apply py-4 px-2; @@ -86,4 +97,25 @@ hr { .content-width-sm { width: 650px; -} \ No newline at end of file +} + +.animate-dash { + animation: dash 1.5s ease-in-out infinite +} + +@keyframes dash { + 0% { + stroke-dasharray: 1, 150; + stroke-dashoffset: 0; + } + + 50% { + stroke-dasharray: 90, 150; + stroke-dashoffset: -35; + } + + 100% { + stroke-dasharray: 90, 150; + stroke-dashoffset: -124; + } +} diff --git a/nextjs/tailwind.config.ts b/nextjs/tailwind.config.ts index 4b6e98e023..40f3663d54 100644 --- a/nextjs/tailwind.config.ts +++ b/nextjs/tailwind.config.ts @@ -16,6 +16,9 @@ export default { full: '9999px', }, extend: { + colors: { + green: '#02b999', + }, // TODO: Update when Brave leo has these variables spacing: { px: '1px', @@ -56,4 +59,4 @@ export default { }, }, }, -} satisfies Config; \ No newline at end of file +} satisfies Config;