Compare commits
5 commits
c18b79e570
...
5fab66444f
Author | SHA1 | Date | |
---|---|---|---|
5fab66444f | |||
06f7019330 | |||
eac0a17473 | |||
aa756ac56a | |||
42041d49bc |
21 changed files with 150 additions and 28 deletions
11
.config/dotnet-tools.json
Normal file
11
.config/dotnet-tools.json
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"husky": {
|
||||||
|
"version": "0.7.1",
|
||||||
|
"commands": ["husky"],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
.husky/pre-commit
Executable file
22
.husky/pre-commit
Executable file
|
@ -0,0 +1,22 @@
|
||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
## husky task runner examples -------------------
|
||||||
|
## Note : for local installation use 'dotnet' prefix. e.g. 'dotnet husky'
|
||||||
|
|
||||||
|
## run all tasks
|
||||||
|
#husky run
|
||||||
|
|
||||||
|
### run all tasks with group: 'group-name'
|
||||||
|
#husky run --group group-name
|
||||||
|
|
||||||
|
## run task with name: 'task-name'
|
||||||
|
#husky run --name task-name
|
||||||
|
|
||||||
|
## pass hook arguments to task
|
||||||
|
#husky run --args "$1" "$2"
|
||||||
|
|
||||||
|
## or put your custom commands -------------------
|
||||||
|
#echo 'Husky.Net is awesome!'
|
||||||
|
|
||||||
|
dotnet husky run
|
16
.husky/task-runner.json
Normal file
16
.husky/task-runner.json
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://alirezanet.github.io/Husky.Net/schema.json",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"name": "run-prettier",
|
||||||
|
"command": "yarn",
|
||||||
|
"args": ["format"],
|
||||||
|
"pathMode": "absolute"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dotnet-format",
|
||||||
|
"command": "dotnet",
|
||||||
|
"args": ["format"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -2,5 +2,6 @@
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="EslintConfiguration">
|
<component name="EslintConfiguration">
|
||||||
<files-pattern value="**/*.{js,ts,jsx,tsx,html,vue,svelte}" />
|
<files-pattern value="**/*.{js,ts,jsx,tsx,html,vue,svelte}" />
|
||||||
|
<option name="fix-on-save" value="true" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="PrettierConfiguration">
|
<component name="PrettierConfiguration">
|
||||||
<option name="myConfigurationMode" value="MANUAL" />
|
<option name="myConfigurationMode" value="AUTOMATIC" />
|
||||||
<option name="myRunOnSave" value="true" />
|
<option name="myRunOnSave" value="true" />
|
||||||
<option name="myRunOnReformat" value="true" />
|
<option name="myRunOnReformat" value="true" />
|
||||||
<option name="myFilesPattern" value="**/*.{js,ts,jsx,tsx,vue,astro,svelte,html}" />
|
<option name="myFilesPattern" value="**/*.{js,ts,jsx,tsx,vue,astro,svelte,html}" />
|
||||||
|
|
|
@ -1,14 +1,18 @@
|
||||||
using System.Web;
|
using System.Web;
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers.Authentication;
|
namespace Foxnouns.Backend.Controllers.Authentication;
|
||||||
|
|
||||||
[Route("/api/v2/auth")]
|
[Route("/api/internal/auth")]
|
||||||
public class AuthController(Config config, KeyCacheService keyCache, ILogger logger) : ApiControllerBase
|
public class AuthController(Config config, DatabaseContext db, KeyCacheService keyCache, ILogger logger)
|
||||||
|
: ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<AuthController>();
|
private readonly ILogger _logger = logger.ForContext<AuthController>();
|
||||||
|
|
||||||
|
@ -61,4 +65,15 @@ public class AuthController(Config config, KeyCacheService keyCache, ILogger log
|
||||||
public record OauthRegisterRequest(string Ticket, string Username);
|
public record OauthRegisterRequest(string Ticket, string Username);
|
||||||
|
|
||||||
public record CallbackRequest(string Code, string State);
|
public record CallbackRequest(string Code, string State);
|
||||||
|
|
||||||
|
[HttpPost("force-log-out")]
|
||||||
|
[Authorize("identify")]
|
||||||
|
public async Task<IActionResult> ForceLogoutAsync()
|
||||||
|
{
|
||||||
|
_logger.Information("Invalidating all tokens for user {UserId}", CurrentUser!.Id);
|
||||||
|
await db.Tokens.Where(t => t.UserId == CurrentUser.Id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ManuallyExpired, true));
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -10,7 +10,7 @@ using NodaTime;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers.Authentication;
|
namespace Foxnouns.Backend.Controllers.Authentication;
|
||||||
|
|
||||||
[Route("/api/v2/auth/discord")]
|
[Route("/api/internal/auth/discord")]
|
||||||
public class DiscordAuthController(
|
public class DiscordAuthController(
|
||||||
[UsedImplicitly] Config config,
|
[UsedImplicitly] Config config,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
|
@ -10,7 +11,7 @@ using NodaTime;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers.Authentication;
|
namespace Foxnouns.Backend.Controllers.Authentication;
|
||||||
|
|
||||||
[Route("/api/v2/auth/email")]
|
[Route("/api/internal/auth/email")]
|
||||||
public class EmailAuthController(
|
public class EmailAuthController(
|
||||||
[UsedImplicitly] Config config,
|
[UsedImplicitly] Config config,
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
|
@ -123,9 +124,20 @@ public class EmailAuthController(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("add")]
|
||||||
|
[Authorize("*")]
|
||||||
|
public async Task<IActionResult> AddEmailAddressAsync()
|
||||||
|
{
|
||||||
|
_logger.Information("beep");
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AddEmailAddressRequest(string Email, string Password);
|
||||||
|
|
||||||
private void CheckRequirements()
|
private void CheckRequirements()
|
||||||
{
|
{
|
||||||
if (!config.DiscordAuth.Enabled)
|
if (!config.EmailAuth.Enabled)
|
||||||
throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
|
throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -186,7 +186,8 @@ public class UsersController(
|
||||||
|
|
||||||
if (preferences.Count > MaxCustomPreferences)
|
if (preferences.Count > MaxCustomPreferences)
|
||||||
errors.Add(("custom_preferences",
|
errors.Add(("custom_preferences",
|
||||||
ValidationError.LengthError("Too many custom preferences", 0, MaxCustomPreferences, preferences.Count)));
|
ValidationError.LengthError("Too many custom preferences", 0, MaxCustomPreferences,
|
||||||
|
preferences.Count)));
|
||||||
if (preferences.Count > 50) return errors;
|
if (preferences.Count > 50) return errors;
|
||||||
|
|
||||||
// TODO: validate individual preferences
|
// TODO: validate individual preferences
|
||||||
|
|
|
@ -27,7 +27,7 @@ public static class AuthUtils
|
||||||
|
|
||||||
public static string[] ExpandScopes(this string[] scopes)
|
public static string[] ExpandScopes(this string[] scopes)
|
||||||
{
|
{
|
||||||
if (scopes.Contains("*")) return Scopes;
|
if (scopes.Contains("*")) return ["*", ..Scopes];
|
||||||
List<string> expandedScopes = ["identify"];
|
List<string> expandedScopes = ["identify"];
|
||||||
if (scopes.Contains("user")) expandedScopes.AddRange(UserScopes);
|
if (scopes.Contains("user")) expandedScopes.AddRange(UserScopes);
|
||||||
if (scopes.Contains("member")) expandedScopes.AddRange(MemberScopes);
|
if (scopes.Contains("member")) expandedScopes.AddRange(MemberScopes);
|
||||||
|
|
|
@ -289,6 +289,7 @@ public static partial class ValidationUtils
|
||||||
|
|
||||||
[GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")]
|
[GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")]
|
||||||
private static partial Regex UsernameRegex();
|
private static partial Regex UsernameRegex();
|
||||||
|
|
||||||
[GeneratedRegex("""^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""", RegexOptions.IgnoreCase, "en-NL")]
|
[GeneratedRegex("""^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""", RegexOptions.IgnoreCase, "en-NL")]
|
||||||
private static partial Regex MemberRegex();
|
private static partial Regex MemberRegex();
|
||||||
}
|
}
|
|
@ -2,4 +2,5 @@ import "dotenv/config";
|
||||||
import { env } from "node:process";
|
import { env } from "node:process";
|
||||||
|
|
||||||
export const API_BASE = env.API_BASE || "https://pronouns.localhost/api";
|
export const API_BASE = env.API_BASE || "https://pronouns.localhost/api";
|
||||||
|
export const INTERNAL_API_BASE = env.INTERNAL_API_BASE || "https://localhost:5000/api";
|
||||||
export const LANGUAGE = env.LANGUAGE || "en";
|
export const LANGUAGE = env.LANGUAGE || "en";
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { parse as parseCookie, serialize as serializeCookie } from "cookie";
|
import { parse as parseCookie, serialize as serializeCookie } from "cookie";
|
||||||
import { API_BASE } from "~/env.server";
|
import { API_BASE, INTERNAL_API_BASE } from "~/env.server";
|
||||||
import { ApiError, ErrorCode } from "./api/error";
|
import { ApiError, ErrorCode } from "./api/error";
|
||||||
import { tokenCookieName } from "~/lib/utils";
|
import { tokenCookieName } from "~/lib/utils";
|
||||||
|
|
||||||
|
@ -8,14 +8,17 @@ export type RequestParams = {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
body?: any;
|
body?: any;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
isInternal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function serverRequest<T>(
|
async function requestInternal(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
params: RequestParams = {},
|
params: RequestParams = {},
|
||||||
) {
|
): Promise<Response> {
|
||||||
const url = `${API_BASE}/v2${path}`;
|
const base = params.isInternal ? INTERNAL_API_BASE + "/internal" : API_BASE + "/v2";
|
||||||
|
|
||||||
|
const url = `${base}${path}`;
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
body: params.body ? JSON.stringify(params.body) : undefined,
|
body: params.body ? JSON.stringify(params.body) : undefined,
|
||||||
|
@ -37,6 +40,19 @@ export default async function serverRequest<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.status < 200 || resp.status >= 400) throw (await resp.json()) as ApiError;
|
if (resp.status < 200 || resp.status >= 400) throw (await resp.json()) as ApiError;
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fastRequest(method: string, path: string, params: RequestParams = {}) {
|
||||||
|
await requestInternal(method, path, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function serverRequest<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
params: RequestParams = {},
|
||||||
|
) {
|
||||||
|
const resp = await requestInternal(method, path, params);
|
||||||
return (await resp.json()) as T;
|
return (await resp.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
|
||||||
const resp = await serverRequest<CallbackResponse>("POST", "/auth/discord/callback", {
|
const resp = await serverRequest<CallbackResponse>("POST", "/auth/discord/callback", {
|
||||||
body: { code, state },
|
body: { code, state },
|
||||||
|
isInternal: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.has_account) {
|
if (resp.has_account) {
|
||||||
|
@ -89,6 +90,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
try {
|
try {
|
||||||
const resp = await serverRequest<AuthResponse>("POST", "/auth/discord/register", {
|
const resp = await serverRequest<AuthResponse>("POST", "/auth/discord/register", {
|
||||||
body: { username, ticket },
|
body: { username, ticket },
|
||||||
|
isInternal: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return redirect("/auth/welcome", {
|
return redirect("/auth/welcome", {
|
||||||
|
|
|
@ -41,7 +41,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const urls = await serverRequest<AuthUrls>("POST", "/auth/urls");
|
const urls = await serverRequest<AuthUrls>("POST", "/auth/urls", { isInternal: true });
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
meta: { title: t("log-in.title") },
|
meta: { title: t("log-in.title") },
|
||||||
|
@ -57,6 +57,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
try {
|
try {
|
||||||
const resp = await serverRequest<AuthResponse>("POST", "/auth/email/login", {
|
const resp = await serverRequest<AuthResponse>("POST", "/auth/email/login", {
|
||||||
body: { email, password },
|
body: { email, password },
|
||||||
|
isInternal: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return redirect("/", {
|
return redirect("/", {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Button, Form, InputGroup, Table } from "react-bootstrap";
|
import { Button, Form, InputGroup, Table } from "react-bootstrap";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Form as RemixForm, useActionData, useRouteLoaderData } from "@remix-run/react";
|
import { Form as RemixForm, useActionData, useFetcher, useRouteLoaderData } from "@remix-run/react";
|
||||||
import { loader as settingsLoader } from "../settings/route";
|
import { loader as settingsLoader } from "../settings/route";
|
||||||
import { loader as rootLoader } from "../../root";
|
import { loader as rootLoader } from "../../root";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
@ -43,6 +43,7 @@ export default function SettingsIndex() {
|
||||||
const actionData = useActionData<typeof action>();
|
const actionData = useActionData<typeof action>();
|
||||||
const { meta } = useRouteLoaderData<typeof rootLoader>("root")!;
|
const { meta } = useRouteLoaderData<typeof rootLoader>("root")!;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const fetcher = useFetcher();
|
||||||
|
|
||||||
const createdAt = idTimestamp(user.id);
|
const createdAt = idTimestamp(user.id);
|
||||||
|
|
||||||
|
@ -55,13 +56,7 @@ export default function SettingsIndex() {
|
||||||
<Form.Group className="mb-3" controlId="username">
|
<Form.Group className="mb-3" controlId="username">
|
||||||
<Form.Label>{t("settings.general.username")}</Form.Label>
|
<Form.Label>{t("settings.general.username")}</Form.Label>
|
||||||
<InputGroup className="m-1 w-75">
|
<InputGroup className="m-1 w-75">
|
||||||
<Form.Control
|
<Form.Control defaultValue={user.username} name="username" type="text" required />
|
||||||
defaultValue={user.username}
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Button variant="secondary" type="submit">
|
<Button variant="secondary" type="submit">
|
||||||
{t("settings.general.change-username")}
|
{t("settings.general.change-username")}
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -85,9 +80,14 @@ export default function SettingsIndex() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4>{t("settings.general.log-out-everywhere")}</h4>
|
<h4>{t("settings.general.log-out-everywhere")}</h4>
|
||||||
<p></p>
|
<p>{t("settings.general.log-out-everywhere-hint")}</p>
|
||||||
|
<fetcher.Form method="POST" action="/settings/force-log-out">
|
||||||
|
<Button type="submit" variant="danger">
|
||||||
|
{t("settings.general.force-log-out-button")}
|
||||||
|
</Button>
|
||||||
|
</fetcher.Form>
|
||||||
</div>
|
</div>
|
||||||
<h4>{t("settings.general.table-header")}</h4>
|
<h4 className="mt-2">{t("settings.general.table-header")}</h4>
|
||||||
<Table striped bordered hover>
|
<Table striped bordered hover>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { ActionFunction, redirect } from "@remix-run/node";
|
||||||
|
import { fastRequest, getToken, writeCookie } from "~/lib/request.server";
|
||||||
|
import { tokenCookieName } from "~/lib/utils";
|
||||||
|
|
||||||
|
export const action: ActionFunction = async ({ request }) => {
|
||||||
|
const token = getToken(request);
|
||||||
|
if (!token)
|
||||||
|
return redirect("/", {
|
||||||
|
status: 303,
|
||||||
|
headers: { "Set-Cookie": writeCookie(tokenCookieName, "token", 0) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await fastRequest("POST", "/auth/force-log-out", { token, isInternal: true });
|
||||||
|
|
||||||
|
return redirect("/", {
|
||||||
|
status: 303,
|
||||||
|
headers: { "Set-Cookie": writeCookie(tokenCookieName, "token", 0) },
|
||||||
|
});
|
||||||
|
};
|
|
@ -96,6 +96,8 @@
|
||||||
"change-username": "Change username",
|
"change-username": "Change username",
|
||||||
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
|
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
|
||||||
"log-out-everywhere": "Log out everywhere",
|
"log-out-everywhere": "Log out everywhere",
|
||||||
|
"log-out-everywhere-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.",
|
||||||
|
"force-log-out-button": "Force log out",
|
||||||
"table-header": "General account information",
|
"table-header": "General account information",
|
||||||
"id": "Your user ID",
|
"id": "Your user ID",
|
||||||
"created": "Account created at",
|
"created": "Account created at",
|
||||||
|
|
|
@ -18,6 +18,7 @@ services:
|
||||||
build: ./Foxnouns.Frontend
|
build: ./Foxnouns.Frontend
|
||||||
environment:
|
environment:
|
||||||
- "API_BASE=http://rate:5003/api"
|
- "API_BASE=http://rate:5003/api"
|
||||||
|
- "INTERNAL_API_BASE=http://backend:5000/api"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/frontend.env:/app/.env
|
- ./docker/frontend.env:/app/.env
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
"concurrently": "^9.0.1"
|
"concurrently": "^9.0.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -n .net,node,rate -c magenta,yellow,blue -i 'cd Foxnouns.Backend && dotnet watch --no-hot-reload' 'cd Foxnouns.Frontend && yarn dev' 'cd rate && go run -v .'"
|
"dev": "concurrently -n .net,node,rate -c magenta,yellow,blue -i 'cd Foxnouns.Backend && dotnet watch --no-hot-reload' 'cd Foxnouns.Frontend && yarn dev' 'cd rate && go run -v .'",
|
||||||
|
"format": "cd Foxnouns.Frontend && yarn format"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue