diff --git a/package.json b/package.json index a8672631f7..84208df91f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "wrangler": "^2.0.6" }, "standard": { + "globals": ["fetch"], "ignore": [ "packages/website" ] diff --git a/packages/api/src/index.js b/packages/api/src/index.js index 00abd1959c..c4ae69a767 100644 --- a/packages/api/src/index.js +++ b/packages/api/src/index.js @@ -7,7 +7,7 @@ import { envAll } from './env.js' import { statusGet } from './status.js' import { carHead, carGet, carPut, carPost } from './car.js' import { uploadPost } from './upload.js' -import { userLoginPost, userTokensPost, userTokensGet, userTokensDelete, userUploadsGet, userUploadsDelete, userAccountGet, userUploadsRename, userInfoGet } from './user.js' +import { userLoginPost, userTokensPost, userTokensGet, userTokensDelete, userUploadsGet, userUploadsDelete, userAccountGet, userUploadsRename, userInfoGet, userRequestPost } from './user.js' import { pinDelete, pinGet, pinPost, pinsGet } from './pins.js' import { metricsGet } from './metrics.js' import { versionGet } from './version.js' @@ -91,6 +91,7 @@ router.delete('/user/uploads/:cid', auth['👤🗑️'](userUploadsDelete)) router.post('/user/uploads/:cid/rename', auth['👤'](userUploadsRename)) router.get('/user/tokens', auth['👤'](userTokensGet)) router.post('/user/tokens', auth['👤'](userTokensPost)) +router.post('/user/request', auth['👤'](userRequestPost)) router.delete('/user/tokens/:id', auth['👤🗑️'](userTokensDelete)) router.get('/user/account', auth['👤'](userAccountGet)) router.get('/user/info', auth['👤'](userInfoGet)) diff --git a/packages/api/src/user.js b/packages/api/src/user.js index cbca295f95..2d33d9c59f 100644 --- a/packages/api/src/user.js +++ b/packages/api/src/user.js @@ -2,12 +2,13 @@ import * as JWT from './utils/jwt.js' import { JSONResponse } from './utils/json-response.js' import { JWT_ISSUER } from './constants.js' import { HTTPError } from './errors.js' -import { getTagValue, hasTag } from './utils/tags.js' +import { getTagValue, hasPendingTagProposal, hasTag } from './utils/tags.js' import { NO_READ_OR_WRITE, READ_WRITE, maintenanceHandler } from './maintenance.js' + /** * @typedef {{ _id: string, issuer: string }} User * @typedef {{ _id: string, name: string }} AuthToken @@ -42,15 +43,16 @@ async function loginOrRegister (request, env) { throw new Error('missing required metadata') } - const parsed = data.type === 'github' - ? parseGitHub(data.data, metadata) - : parseMagic(metadata) + const parsed = + data.type === 'github' + ? parseGitHub(data.data, metadata) + : parseMagic(metadata) let user // check if maintenance mode - if (env.mode === NO_READ_OR_WRITE) { + if (env.MODE === NO_READ_OR_WRITE) { return maintenanceHandler() - } else if (env.mode === READ_WRITE) { + } else if (env.MODE === READ_WRITE) { user = await env.db.upsertUser(parsed) } else { user = await env.db.getUser(parsed.issuer) @@ -141,7 +143,10 @@ export async function userAccountGet (request, env) { * @param {import('./env').Env} env */ export async function userInfoGet (request, env) { - const user = await env.db.getUser(request.auth.user.issuer, { includeTags: true }) + const user = await env.db.getUser(request.auth.user.issuer, { + includeTags: true, + includeTagProposals: true + }) return new JSONResponse({ info: { @@ -152,11 +157,43 @@ export async function userInfoGet (request, env) { HasPsaAccess: hasTag(user, 'HasPsaAccess', 'true'), HasSuperHotAccess: hasTag(user, 'HasSuperHotAccess', 'true'), StorageLimitBytes: getTagValue(user, 'StorageLimitBytes', '') + }, + tagProposals: { + HasAccountRestriction: hasPendingTagProposal(user, 'HasAccountRestriction'), + HasDeleteRestriction: hasPendingTagProposal(user, 'HasDeleteRestriction'), + HasPsaAccess: hasPendingTagProposal(user, 'HasPsaAccess'), + HasSuperHotAccess: hasPendingTagProposal(user, 'HasSuperHotAccess'), + StorageLimitBytes: hasPendingTagProposal(user, 'StorageLimitBytes') } } }) } +/** + * Post a new user request. + * + * @param {AuthenticatedRequest} request + * @param {import('./env').Env} env + */ +export async function userRequestPost (request, env) { + const user = request.auth.user + const { tagName, requestedTagValue, userProposalForm } = await request.json() + const res = await env.db.createUserRequest( + user._id, + tagName, + requestedTagValue, + userProposalForm + ) + + try { + notifySlack(user, tagName, requestedTagValue, userProposalForm, env) + } catch (e) { + console.error('Failed to notify Slack: ', e) + } + + return new JSONResponse(res) +} + /** * Retrieve user auth tokens. * @@ -220,9 +257,16 @@ export async function userUploadsGet (request, env) { }) const oldest = uploads[uploads.length - 1] - const headers = uploads.length === size - ? { Link: `<${requestUrl.pathname}?size=${size}&before=${encodeURIComponent(oldest.created)}>; rel="next"` } - : undefined + const headers = + uploads.length === size + ? { + Link: `<${ + requestUrl.pathname + }?size=${size}&before=${encodeURIComponent( + oldest.created + )}>; rel="next"` + } + : undefined return new JSONResponse(uploads, { headers }) } @@ -259,3 +303,67 @@ export async function userUploadsRename (request, env) { const res = await env.db.renameUpload(user, cid, name) return new JSONResponse(res) } + +/** + * + * @param {number} userId + * @param {string} userProposalForm + * @param {string} tagName + * @param {string} requestedTagValue + * @param {DBClient} db + */ +const notifySlack = async ( + user, + tagName, + requestedTagValue, + userProposalForm, + env +) => { + const webhookUrl = env.SLACK_USER_REQUEST_WEBHOOK_URL + + if (!webhookUrl) { + return + } + + /** @type {import('../bindings').RequestForm} */ + let form + try { + form = JSON.parse(userProposalForm) + } catch (e) { + console.error('Failed to parse user request form: ', e) + return + } + + fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify({ + text: ` +>*Username* +>${user.name} +> +>*Email* +>${user.email} +> +>*User Id* +>${user._id} +> +>*Requested Tag Name* +>${tagName} +> +>*Requested Tag Value* +>${requestedTagValue} +>${form + .map( + ({ label, value }) => ` +>*${label}* +>${value} +>` + ) + .join('')} +` + }) + }) +} diff --git a/packages/api/src/utils/tags.js b/packages/api/src/utils/tags.js index 5afc885fc3..bcc4f65bb9 100644 --- a/packages/api/src/utils/tags.js +++ b/packages/api/src/utils/tags.js @@ -24,3 +24,14 @@ export function hasTag (user, tagName, value) { ) ) } + +export function hasPendingTagProposal (user, tagName) { + return Boolean( + user.tagProposals?.find( + (proposal) => + proposal.tag === tagName && + !proposal.admin_decision_type && + !proposal.deleted_at + ) + ) +} diff --git a/packages/db/index.js b/packages/db/index.js index 6f45eda6b0..4956838f4d 100644 --- a/packages/db/index.js +++ b/packages/db/index.js @@ -23,8 +23,8 @@ const uploadQuery = ` content(cid, dagSize:dag_size, pins:pin(status, updated:updated_at, location:pin_location(_id:id, peerId:peer_id, peerName:peer_name, ipfsPeerId:ipfs_peer_id, region))) ` -const userQuery = ` - _id:id::text, +const getUserQuery = options => ` + _id:id::text, issuer, name, email, @@ -32,18 +32,8 @@ const userQuery = ` publicAddress:public_address, created:inserted_at, updated:updated_at -` - -const userQueryWithTags = ` - _id:id::text, - issuer, - name, - email, - github, - publicAddress:public_address, - created:inserted_at, - updated:updated_at, - tags:user_tag_user_id_fkey(user_id,id,tag,value,deleted_at) + ${options?.includeTags ? ',tags:user_tag_user_id_fkey(user_id,id,tag,value,deleted_at)' : ''} + ${options?.includeTagProposals ? ',tagProposals:user_tag_proposal_user_id_fkey(user_id,id,admin_decision_type,tag,proposed_tag_value,deleted_at)' : ''} ` const psaPinRequestTableName = 'psa_pin_request' @@ -111,17 +101,20 @@ export class DBClient { /** @type {{ data: definitions['user'], error: PostgrestError }} */ const { data, error } = await this._client .from('user') - .upsert({ - id: user.id, - name: user.name, - picture: user.picture, - email: user.email, - issuer: user.issuer, - github: user.github, - public_address: user.publicAddress - }, { - onConflict: 'issuer' - }) + .upsert( + { + id: user.id, + name: user.name, + picture: user.picture, + email: user.email, + issuer: user.issuer, + github: user.github, + public_address: user.publicAddress + }, + { + onConflict: 'issuer' + } + ) .single() if (error) { @@ -140,11 +133,11 @@ export class DBClient { * @param {import('./db-client-types').GetUserOptions?} options * @return {Promise} */ - async getUser (issuer, { includeTags } = { includeTags: false }) { + async getUser (issuer, options) { /** @type {{ data: import('./db-client-types').UserOutput[], error: PostgrestError }} */ const { data, error } = await this._client .from('user') - .select(includeTags ? userQueryWithTags : userQuery) + .select(getUserQuery(options)) .eq('issuer', issuer) if (error) { @@ -163,7 +156,7 @@ export class DBClient { /** @type {{ data: import('./db-client-types').UserOutput[], error: PostgrestError }} */ const { data, error } = await this._client .from('user') - .select(userQuery) + .select(getUserQuery()) .eq('email', email) if (error) { @@ -215,6 +208,56 @@ export class DBClient { return false } + /** + * Creates a user tag change request + * + * @param {string} userId + * @param {string} tagName + * @param {string} requestedTagValue + * @param {JSON} userProposalForm + * @returns + */ + async createUserRequest ( + userId, + tagName, + requestedTagValue, + userProposalForm + ) { + const { data: deleteData, status: deleteStatus } = await this._client + .from('user_tag_proposal') + .update({ + deleted_at: new Date().toISOString() + }) + .match({ user_id: userId, tag: tagName }) + .is('deleted_at', null) + + if ( + deleteStatus === 200 || + ((deleteStatus === 406 || deleteStatus === 404) && !deleteData) + ) { + const { error: insertError, status: insertStatus } = await this._client + .from('user_tag_proposal') + .insert({ + user_id: userId, + tag: tagName, + proposed_tag_value: requestedTagValue, + inserted_at: new Date().toISOString(), + user_proposal_form: userProposalForm + }) + .single() + + if (insertError) { + throw new DBError(insertError) + } + + if (insertStatus === 201) { + return true + } + } + + return false + } + /** * Returns the value stored for an active (non-deleted) user tag. * @@ -236,7 +279,9 @@ export class DBClient { // Expects unique entries. if (data.length > 1) { - throw new ConstraintError({ message: `More than one row found for user tag ${tag}` }) + throw new ConstraintError({ + message: `More than one row found for user tag ${tag}` + }) } return data.length ? data[0].value : undefined @@ -251,10 +296,12 @@ export class DBClient { async getUserTags (userId) { const { data, error } = await this._client .from('user_tag') - .select(` + .select( + ` tag, value - `) + ` + ) .eq('user_id', userId) .filter('deleted_at', 'is', null) @@ -264,9 +311,11 @@ export class DBClient { // Ensure active user tags are unique. const tags = new Set() - data.forEach(item => { + data.forEach((item) => { if (tags.has(item.tag)) { - throw new ConstraintError({ message: `More than one row found for user tag ${item.tag}` }) + throw new ConstraintError({ + message: `More than one row found for user tag ${item.tag}` + }) } tags.add(item.tag) }) @@ -282,7 +331,9 @@ export class DBClient { */ async getStorageUsed (userId) { /** @type {{ data: { uploaded: string, psa_pinned: string, total: string }, error: PostgrestError }} */ - const { data, error } = await this._client.rpc('user_used_storage', { query_user_id: userId }).single() + const { data, error } = await this._client + .rpc('user_used_storage', { query_user_id: userId }) + .single() if (error) { throw new DBError(error) @@ -295,6 +346,37 @@ export class DBClient { } } + /** + * Get all users with storage used in a percentage range of their allocated quota + * @param {import('./db-client-types').UserStorageUsedInput} percentRange + * @returns {Promise>} + */ + async getUsersByStorageUsed (percentRange) { + const { fromPercent, toPercent = null } = percentRange + + const { data, error } = await this._client.rpc('users_by_storage_used', { + from_percent: fromPercent, + to_percent: toPercent + }) + + if (error) { + throw new DBError(error) + } + + return data.map((user) => { + return { + id: user.id, + name: user.name, + email: user.email, + storageQuota: user.storage_quota, + storageUsed: user.storage_used, + percentStorageUsed: Math.floor( + (user.storage_used / user.storage_quota) * 100 + ) + } + }) + } + /** * Check the email history for a specified email type to see if it has * been sent within a specified number of days. If not, it is resent. @@ -309,7 +391,9 @@ export class DBClient { } = email const lastSentAtDate = new Date() - lastSentAtDate.setSeconds(lastSentAtDate.getSeconds() - secondsSinceLastSent) + lastSentAtDate.setSeconds( + lastSentAtDate.getSeconds() - secondsSinceLastSent + ) const lastSentAt = lastSentAtDate.toISOString() const { count, error } = await this._client @@ -332,19 +416,13 @@ export class DBClient { * @returns {Promise} */ async logEmailSent (email) { - const { - userId, - emailType, - messageId - } = email + const { userId, emailType, messageId } = email - const { data, error } = await this._client - .from('email_history') - .upsert({ - user_id: userId, - email_type: emailType, - message_id: messageId - }) + const { data, error } = await this._client.from('email_history').upsert({ + user_id: userId, + email_type: emailType, + message_id: messageId + }) if (error) { throw new DBError(error) @@ -363,29 +441,31 @@ export class DBClient { const now = new Date().toISOString() /** @type {{ data: string, error: PostgrestError }} */ - const { data: uploadResponse, error } = await this._client.rpc('create_upload', { - data: { - user_id: data.user, - auth_key_id: data.authKey, - content_cid: data.contentCid, - source_cid: data.sourceCid, - type: data.type, - name: data.name, - dag_size: data.dagSize, - inserted_at: data.created || now, - updated_at: data.updated || now, - pins: data.pins.map(pin => ({ - status: pin.status, - location: { - peer_id: pin.location.peerId, - peer_name: pin.location.peerName, - ipfs_peer_id: pin.location.ipfsPeerId, - region: pin.location.region - } - })), - backup_urls: data.backupUrls - } - }).single() + const { data: uploadResponse, error } = await this._client + .rpc('create_upload', { + data: { + user_id: data.user, + auth_key_id: data.authKey, + content_cid: data.contentCid, + source_cid: data.sourceCid, + type: data.type, + name: data.name, + dag_size: data.dagSize, + inserted_at: data.created || now, + updated_at: data.updated || now, + pins: data.pins.map((pin) => ({ + status: pin.status, + location: { + peer_id: pin.location.peerId, + peer_name: pin.location.peerName, + ipfs_peer_id: pin.location.ipfsPeerId, + region: pin.location.region + } + })), + backup_urls: data.backupUrls + } + }) + .single() if (error) { throw new DBError(error) @@ -440,10 +520,9 @@ export class DBClient { .eq('user_id', userId) .is('deleted_at', null) .limit(opts.size || 10) - .order( - opts.sortBy === 'Name' ? 'name' : 'inserted_at', - { ascending: opts.sortOrder === 'Asc' } - ) + .order(opts.sortBy === 'Name' ? 'name' : 'inserted_at', { + ascending: opts.sortOrder === 'Asc' + }) if (opts.before) { query = query.lt('inserted_at', opts.before) @@ -543,12 +622,14 @@ export class DBClient { /** @type {{ data: Array, error: PostgrestError }} */ const { data, error } = await this._client .from('content') - .select(` + .select( + ` cid, dagSize:dag_size, created:inserted_at, pins:pin(status, updated:updated_at, location:pin_location(peerId:peer_id, peerName:peer_name, ipfsPeerId:ipfs_peer_id, region)) - `) + ` + ) .match({ cid }) if (error) { @@ -574,7 +655,10 @@ export class DBClient { */ async getBackups (uploadId) { /** @type {{ data: {backupUrls: definitions['upload']['backup_urls']}, error: PostgrestError }} */ - const { data: { backupUrls }, error } = await this._client + const { + data: { backupUrls }, + error + } = await this._client .from('upload') .select('backupUrls:backup_urls') .eq('id', uploadId) @@ -598,20 +682,22 @@ export class DBClient { */ async upsertPin (cid, pin) { /** @type {{ data: string, error: PostgrestError }} */ - const { data: pinId, error } = await this._client.rpc('upsert_pin', { - data: { - content_cid: cid, - pin: { - status: pin.status, - location: { - peer_id: pin.location.peerId, - peer_name: pin.location.peerName, - ipfs_peer_id: pin.location.ipfsPeerId, - region: pin.location.region + const { data: pinId, error } = await this._client + .rpc('upsert_pin', { + data: { + content_cid: cid, + pin: { + status: pin.status, + location: { + peer_id: pin.location.peerId, + peer_name: pin.location.peerName, + ipfs_peer_id: pin.location.ipfsPeerId, + region: pin.location.region + } } } - } - }).single() + }) + .single() if (error) { throw new DBError(error) @@ -628,24 +714,26 @@ export class DBClient { * @param {Array} pins */ async upsertPins (pins) { - const { data: pinIds, error } = await this._client.rpc('upsert_pins', { - data: { - pins: pins.map((pin) => ({ - data: { - content_cid: pin.contentCid, - pin: { - status: pin.status, - location: { - peer_id: pin.location.peerId, - peer_name: pin.location.peerName, - ipfs_peer_id: pin.location.ipfsPeerId, - region: pin.location.region + const { data: pinIds, error } = await this._client + .rpc('upsert_pins', { + data: { + pins: pins.map((pin) => ({ + data: { + content_cid: pin.contentCid, + pin: { + status: pin.status, + location: { + peer_id: pin.location.peerId, + peer_name: pin.location.peerName, + ipfs_peer_id: pin.location.ipfsPeerId, + region: pin.location.region + } } } - } - })) - } - }).single() + })) + } + }) + .single() if (error) { throw new DBError(error) @@ -664,13 +752,15 @@ export class DBClient { /** @type {{ data: Array, error: PostgrestError }} */ const { data: pins, error } = await this._client .from('pin') - .select(` + .select( + ` _id:id::text, status, created:inserted_at, updated:updated_at, location:pin_location(id::text, peerId:peer_id, peerName:peer_name, ipfsPeerId:ipfs_peer_id, region) - `) + ` + ) .match({ content_cid: cid }) if (error) { @@ -691,11 +781,13 @@ export class DBClient { /** @type {{ data: Array, error: PostgrestError }} */ const { data: pinReqs, error } = await this._client .from('pin_request') - .select(` + .select( + ` _id:id::text, cid:content_cid, created:inserted_at - `) + ` + ) .limit(size) if (error) { @@ -730,14 +822,15 @@ export class DBClient { */ async createPinSyncRequests (pinSyncRequests) { /** @type {{ error: PostgrestError }} */ - const { error } = await this._client - .from('pin_sync_request') - .upsert(pinSyncRequests.map(psr => ({ + const { error } = await this._client.from('pin_sync_request').upsert( + pinSyncRequests.map((psr) => ({ pin_id: psr, inserted_at: new Date().toISOString() - })), { + })), + { onConflict: 'pin_id' - }) + } + ) if (error) { throw new DBError(error) @@ -756,14 +849,13 @@ export class DBClient { async getPinSyncRequests ({ to, after, size }) { let query = this._client .from('pin_sync_request') - .select(` + .select( + ` _id:id::text, pin:pin(_id:id::text, status, contentCid:content_cid, created:inserted_at, location:pin_location(_id:id::text, peerId:peer_id, peerName:peer_name, ipfsPeerId:ipfs_peer_id, region)) - `) - .order( - 'inserted_at', - { ascending: true } + ` ) + .order('inserted_at', { ascending: true }) .limit(size) if (to) { @@ -781,7 +873,8 @@ export class DBClient { return { data: pinSyncReqs, - after: !!size && pinSyncReqs.length === size && pinSyncReqs[0].pin.created // return after if more + after: + !!size && pinSyncReqs.length === size && pinSyncReqs[0].pin.created // return after if more } } @@ -824,10 +917,12 @@ export class DBClient { */ async getDealsForCids (cids = []) { /** @type {{ data: Array, error: PostgrestError }} */ - const { data, error } = await this._client - .rpc('find_deals_by_content_cids', { + const { data, error } = await this._client.rpc( + 'find_deals_by_content_cids', + { cids - }) + } + ) if (error) { return {} @@ -857,15 +952,17 @@ export class DBClient { const now = new Date().toISOString() /** @type {{ data: string, error: PostgrestError }} */ - const { data, error } = await this._client.rpc('create_key', { - data: { - name, - secret, - user_id: user, - inserted_at: now, - updated_at: now - } - }).single() + const { data, error } = await this._client + .rpc('create_key', { + data: { + name, + secret, + user_id: user, + inserted_at: now, + updated_at: now + } + }) + .single() if (error) { throw new DBError(error) @@ -887,7 +984,8 @@ export class DBClient { /** @type {{ data, error: PostgrestError }} */ const { data, error } = await this._client .from('user') - .select(` + .select( + ` _id:id::text, issuer, keys:auth_key_user_id_fkey( @@ -896,7 +994,8 @@ export class DBClient { secret, deleted_at ) - `) + ` + ) .match({ issuer }) @@ -949,13 +1048,15 @@ export class DBClient { */ async listKeys (userId) { /** @type {{ error: PostgrestError, data: Array }} */ - const { data, error } = await this._client.rpc('user_auth_keys_list', { query_user_id: userId }) + const { data, error } = await this._client.rpc('user_auth_keys_list', { + query_user_id: userId + }) if (error) { throw new DBError(error) } - return data.map(ki => ({ + return data.map((ki) => ({ _id: ki.id, name: ki.name, secret: ki.secret, @@ -1028,28 +1129,30 @@ export class DBClient { const now = new Date().toISOString() /** @type {{ data: string, error: PostgrestError }} */ - const { data: pinRequestId, error } = await this._client.rpc('create_psa_pin_request', { - data: { - auth_key_id: pinRequestData.authKey, - content_cid: pinRequestData.contentCid, - source_cid: pinRequestData.sourceCid, - name: pinRequestData.name, - origins: pinRequestData.origins, - meta: pinRequestData.meta, - dag_size: pinRequestData.dagSize, - inserted_at: pinRequestData.created || now, - updated_at: pinRequestData.updated || now, - pins: pinRequestData.pins.map(pin => ({ - status: pin.status, - location: { - peer_id: pin.location.peerId, - peer_name: pin.location.peerName, - ipfs_peer_id: pin.location.ipfsPeerId, - region: pin.location.region - } - })) - } - }).single() + const { data: pinRequestId, error } = await this._client + .rpc('create_psa_pin_request', { + data: { + auth_key_id: pinRequestData.authKey, + content_cid: pinRequestData.contentCid, + source_cid: pinRequestData.sourceCid, + name: pinRequestData.name, + origins: pinRequestData.origins, + meta: pinRequestData.meta, + dag_size: pinRequestData.dagSize, + inserted_at: pinRequestData.created || now, + updated_at: pinRequestData.updated || now, + pins: pinRequestData.pins.map((pin) => ({ + status: pin.status, + location: { + peer_id: pin.location.peerId, + peer_name: pin.location.peerName, + ipfs_peer_id: pin.location.ipfsPeerId, + region: pin.location.region + } + })) + } + }) + .single() if (error) { throw new DBError(error) @@ -1151,13 +1254,13 @@ export class DBClient { } /** @type {{ data: Array, count: number, error: PostgrestError }} */ - const { data, count, error } = (await query) + const { data, count, error } = await query if (error) { throw new DBError(error) } - const pins = data.map(pinRequest => normalizePsaPinRequest(pinRequest)) + const pins = data.map((pinRequest) => normalizePsaPinRequest(pinRequest)) return { count, diff --git a/packages/website/components/accordionblock/accordionblock.scss b/packages/website/components/accordionblock/accordionblock.scss index 4f87acec4c..24c826c591 100644 --- a/packages/website/components/accordionblock/accordionblock.scss +++ b/packages/website/components/accordionblock/accordionblock.scss @@ -36,6 +36,7 @@ margin-left: 4.75rem; @include fontSize_Regular; line-height: leading(34, 18); + .a, a { border-bottom: 0.03125rem solid; padding-bottom: 0.1875rem; diff --git a/packages/website/components/account/storageManager/storageManager.js b/packages/website/components/account/storageManager/storageManager.js index 687f3bfc00..35f9c898a9 100644 --- a/packages/website/components/account/storageManager/storageManager.js +++ b/packages/website/components/account/storageManager/storageManager.js @@ -3,10 +3,10 @@ import filesz from 'filesize'; import { useCallback, useMemo, useEffect, useState, useRef } from 'react'; import LockIcon from 'assets/icons/lock'; -import emailContent from '../../../content/file-a-request'; import Button, { ButtonVariant } from 'components/button/button'; import { useUser } from 'components/contexts/userContext'; import { elementIsInViewport } from 'lib/utils'; +import StorageLimitRequestModal from 'components/storageLimitRequestModal/storageLimitRequestModal'; // Raw TiB number of bytes, to be used in calculations const tebibyte = 1099511627776; @@ -18,10 +18,6 @@ const defaultStorageLimit = tebibyte; * @property {any} [content] */ -const mailTo = `mailto:${emailContent.mail}?subject=${emailContent.subject}&body=${encodeURIComponent( - emailContent.body.join('\n') -)}`; - /** * * @param {StorageManagerProps} props @@ -36,6 +32,7 @@ const StorageManager = ({ className = '', content }) => { const limit = useMemo(() => data?.storageLimitBytes || defaultStorageLimit, [data]); const [componentInViewport, setComponentInViewport] = useState(false); const storageManagerRef = useRef(/** @type {HTMLDivElement | null} */ (null)); + const [isUserRequestModalOpen, setIsUserRequestModalOpen] = useState(false); const { maxSpaceLabel, unlockLabel, percentUploaded, percentPinned } = useMemo( () => ({ @@ -122,11 +119,11 @@ const StorageManager = ({ className = '', content }) => { {maxSpaceLabel} {!!unlockLabel && ( - )} @@ -152,10 +149,13 @@ const StorageManager = ({ className = '', content }) => { + {isUserRequestModalOpen && ( + setIsUserRequestModalOpen(false)} /> + )} ); }; diff --git a/packages/website/components/account/storageManager/storageManager.scss b/packages/website/components/account/storageManager/storageManager.scss index f5a8f6fbe6..ef6ece4cad 100644 --- a/packages/website/components/account/storageManager/storageManager.scss +++ b/packages/website/components/account/storageManager/storageManager.scss @@ -33,7 +33,7 @@ .storage-manager-info { @include fontSize_Tiny; @include fontWeight_Regular; - a { + .storage-manager__storage-request-button { @include hoverTextLinkColor; } margin-top: 1.5625rem; @@ -88,7 +88,7 @@ padding: 0; min-height: auto; width: auto; - a { + > span { display: flex; flex-direction: row; align-items: center; diff --git a/packages/website/components/navigation/navigation.js b/packages/website/components/navigation/navigation.js index fdc3d5bb39..50b28e8d5f 100644 --- a/packages/website/components/navigation/navigation.js +++ b/packages/website/components/navigation/navigation.js @@ -16,8 +16,8 @@ import SiteLogo from '../../assets/icons/w3storage-logo.js'; import Hamburger from '../../assets/icons/hamburger.js'; import GradientBackground from '../gradientbackground/gradientbackground.js'; import GeneralPageData from '../../content/pages/general.json'; -import emailContent from '../../content/file-a-request'; import Search from 'components/search/search'; +import StorageLimitRequestModal from 'components/storageLimitRequestModal/storageLimitRequestModal'; /** * Navbar Component @@ -31,6 +31,7 @@ export default function Navigation({ isProductApp }) { const isLoadingUser = useMemo(() => isLoading || isFetching, [isLoading, isFetching]); // component State const [isMenuOpen, setMenuOpen] = useState(false); + const [isUserRequestModalOpen, setIsUserRequestModalOpen] = useState(false); // Navigation Content const links = GeneralPageData.navigation.links; const account = links?.find(item => item.text.toLowerCase() === 'account'); @@ -40,9 +41,6 @@ export default function Navigation({ isProductApp }) { const logoText = GeneralPageData.site_logo.text; const theme = router.route === '/pricing' || isProductApp ? 'light' : 'dark'; const buttonTheme = isProductApp ? 'pink-blue' : ''; - const mailTo = `mailto:${emailContent.mail}?subject=${emailContent.subject}&body=${encodeURIComponent( - emailContent.body.join('\n') - )}`; const isDocs = router.route.includes('docs'); const toggleMenu = () => { @@ -90,21 +88,31 @@ export default function Navigation({ isProductApp }) { ); @@ -250,22 +258,28 @@ export default function Navigation({ isProductApp }) { {Array.isArray(account.links) && ( )} @@ -283,6 +297,7 @@ export default function Navigation({ isProductApp }) { + setIsUserRequestModalOpen(false)} /> ); } diff --git a/packages/website/components/navigation/navigation.scss b/packages/website/components/navigation/navigation.scss index 4045494f4d..c60424e3bb 100644 --- a/packages/website/components/navigation/navigation.scss +++ b/packages/website/components/navigation/navigation.scss @@ -238,6 +238,7 @@ opacity: 0.05; } } + .nav-dropdown-button, .nav-dropdown-link { position: relative; padding: 0.5rem 0; diff --git a/packages/website/components/pinningRequestModal/pinningRequestModal.js b/packages/website/components/pinningRequestModal/pinningRequestModal.js new file mode 100644 index 0000000000..7011984030 --- /dev/null +++ b/packages/website/components/pinningRequestModal/pinningRequestModal.js @@ -0,0 +1,68 @@ +import { useState } from 'react'; + +import Modal from 'modules/zero/components/modal/modal'; +import CloseIcon from 'assets/icons/close'; +import Button from 'components/button/button.js'; +import { createPinningServiceRequest } from 'lib/api'; + +const PinningRequestModal = ({ isOpen, onClose }) => { + const [requesting, setRequesting] = useState(false); + + async function handleCreateUserRequest(e) { + e.preventDefault(); + const data = new FormData(e.target); + + const reason = data.get('reason'); + const examples = data.get('examples'); + const profile = data.get('profile'); + + if (reason && examples && profile) { + setRequesting(true); + try { + await createPinningServiceRequest(reason, examples, profile); + } finally { + setRequesting(false); + onClose(); + } + } + } + + return ( +
+ } + modalState={[isOpen, onClose]} + showCloseButton + > +
+

Request API Pinning Access

+
+
+ +