fix(docker-watcher): address final review findings
Security: - Move config export behind auth middleware - Validate OIDC callback token before storing in localStorage - Use constant-time comparison for webhook secret - Encrypt OIDC client secret at rest (like registry tokens) Performance: - Make TriggerDeploy async from HTTP handlers (return deploy ID immediately, run pipeline in background goroutine) Robustness: - Wrap api.ts res.json() in try/catch for non-JSON responses i18n: - Replace ~20 hardcoded English validation messages with $t() calls - Localize ConfirmDialog cancel button, InstanceCard confirm titles, ProjectCard instance/instances pluralization - Add validation keys to both en.json and ru.json
This commit is contained in:
+13
-1
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/auth"
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
)
|
||||
|
||||
@@ -207,12 +208,23 @@ func (s *Server) updateAuthSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// If client secret is masked, preserve the existing value.
|
||||
// If client secret is masked, preserve the existing encrypted value.
|
||||
if req.OIDCClientSecret == "********" || req.OIDCClientSecret == "" {
|
||||
existing, err := s.store.GetAuthSettings()
|
||||
if err == nil {
|
||||
req.OIDCClientSecret = existing.OIDCClientSecret
|
||||
}
|
||||
} else {
|
||||
// Encrypt the new client secret before storage.
|
||||
encrypted, err := crypto.Encrypt(s.encKey, req.OIDCClientSecret)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to encrypt OIDC client secret")
|
||||
return
|
||||
}
|
||||
// Keep plaintext for OIDC init below, store encrypted.
|
||||
plaintextSecret := req.OIDCClientSecret
|
||||
req.OIDCClientSecret = encrypted
|
||||
defer func() { req.OIDCClientSecret = plaintextSecret }()
|
||||
}
|
||||
|
||||
if err := s.store.UpdateAuthSettings(req); err != nil {
|
||||
|
||||
@@ -137,16 +137,18 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger deploy.
|
||||
if err := s.deployer.TriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag); err != nil {
|
||||
// Trigger deploy asynchronously.
|
||||
deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusAccepted, map[string]any{
|
||||
"project": project,
|
||||
"stage": stage,
|
||||
"tag": req.Tag,
|
||||
"project": project,
|
||||
"stage": stage,
|
||||
"tag": req.Tag,
|
||||
"deploy_id": deployID,
|
||||
"status": "deploying",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,12 +74,14 @@ func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deployer.TriggerDeploy(r.Context(), projectID, stageID, req.ImageTag); err != nil {
|
||||
deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), projectID, stageID, req.ImageTag)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusAccepted, map[string]string{
|
||||
"status": "deploying",
|
||||
"deploy_id": deployID,
|
||||
"project_id": projectID,
|
||||
"stage_id": stageID,
|
||||
"image_tag": req.ImageTag,
|
||||
@@ -188,4 +190,5 @@ func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action
|
||||
// DeployTriggerer is the interface for triggering deployments.
|
||||
type DeployTriggerer interface {
|
||||
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
|
||||
AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error)
|
||||
}
|
||||
|
||||
+13
-4
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/auth"
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
@@ -57,10 +58,18 @@ func NewServer(
|
||||
|
||||
// initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal.
|
||||
func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) {
|
||||
// Decrypt the OIDC client secret if it's encrypted.
|
||||
clientSecret := as.OIDCClientSecret
|
||||
if clientSecret != "" {
|
||||
if decrypted, err := crypto.Decrypt(s.encKey, clientSecret); err == nil {
|
||||
clientSecret = decrypted
|
||||
}
|
||||
// If decrypt fails, assume it's already plaintext (migration scenario).
|
||||
}
|
||||
provider, err := auth.NewOIDCProvider(ctx, auth.OIDCConfig{
|
||||
IssuerURL: as.OIDCIssuerURL,
|
||||
ClientID: as.OIDCClientID,
|
||||
ClientSecret: as.OIDCClientSecret,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: as.OIDCRedirectURL,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -90,13 +99,13 @@ func (s *Server) Router() chi.Router {
|
||||
// Webhook handler (uses its own secret-based auth).
|
||||
r.Mount("/webhook", s.webhook.Route())
|
||||
|
||||
// Config export (public endpoint, useful for backup).
|
||||
r.Get("/config/export", s.exportConfig)
|
||||
|
||||
// Protected routes: require valid JWT.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.Middleware(s.localAuth))
|
||||
|
||||
// Config export (protected — reveals project/infra details).
|
||||
r.Get("/config/export", s.exportConfig)
|
||||
|
||||
// Auth management.
|
||||
r.Get("/auth/me", s.currentUser)
|
||||
r.Get("/auth/settings", s.getAuthSettings)
|
||||
|
||||
@@ -72,8 +72,54 @@ func (d *Deployer) Drain() {
|
||||
slog.Info("deployer: all deploys drained")
|
||||
}
|
||||
|
||||
// TriggerDeploy is the main entry point for deployments. It orchestrates the full flow:
|
||||
// pull image -> create container -> start -> configure proxy -> health check.
|
||||
// AsyncTriggerDeploy creates a deploy record and returns the deploy ID immediately,
|
||||
// then runs the full deploy pipeline in a background goroutine. Use this from HTTP handlers
|
||||
// to avoid blocking the request. Progress is streamed via SSE.
|
||||
func (d *Deployer) AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error) {
|
||||
if d.shuttingDown.Load() {
|
||||
return "", fmt.Errorf("deployer is shutting down, rejecting new deploy")
|
||||
}
|
||||
|
||||
// Validate inputs synchronously so the caller gets immediate feedback.
|
||||
project, err := d.store.GetProjectByID(projectID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get project: %w", err)
|
||||
}
|
||||
stage, err := d.store.GetStageByID(stageID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get stage: %w", err)
|
||||
}
|
||||
if err := d.validatePromoteFrom(stage, imageTag); err != nil {
|
||||
return "", fmt.Errorf("promote validation: %w", err)
|
||||
}
|
||||
|
||||
// Create deploy record synchronously so caller gets the ID.
|
||||
deploy, err := d.store.CreateDeploy(store.Deploy{
|
||||
ProjectID: projectID,
|
||||
StageID: stageID,
|
||||
ImageTag: imageTag,
|
||||
Status: "pending",
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create deploy record: %w", err)
|
||||
}
|
||||
|
||||
// Run the actual deploy in the background.
|
||||
d.activeWg.Add(1)
|
||||
go func() {
|
||||
defer d.activeWg.Done()
|
||||
// Use a detached context so client disconnect doesn't abort the deploy.
|
||||
bgCtx := context.Background()
|
||||
if err := d.runDeploy(bgCtx, project, stage, deploy.ID, imageTag); err != nil {
|
||||
slog.Error("async deploy failed", "deploy_id", deploy.ID, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return deploy.ID, nil
|
||||
}
|
||||
|
||||
// TriggerDeploy is the synchronous entry point for deployments (used by poller and webhook).
|
||||
// It orchestrates the full flow: pull image -> create container -> start -> configure proxy -> health check.
|
||||
// On failure, it rolls back (removes container, deletes proxy host, updates status).
|
||||
func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error {
|
||||
if d.shuttingDown.Load() {
|
||||
|
||||
@@ -2,6 +2,7 @@ package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -144,7 +145,7 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if settings.WebhookSecret == "" || settings.WebhookSecret != secret {
|
||||
if settings.WebhookSecret == "" || subtle.ConstantTimeCompare([]byte(settings.WebhookSecret), []byte(secret)) != 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
+9
-1
@@ -46,7 +46,15 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
headers
|
||||
});
|
||||
|
||||
const envelope: ApiEnvelope<T> = await res.json();
|
||||
let envelope: ApiEnvelope<T>;
|
||||
try {
|
||||
envelope = await res.json();
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
`Server returned non-JSON response (HTTP ${res.status})`,
|
||||
res.status
|
||||
);
|
||||
}
|
||||
|
||||
if (!envelope.success) {
|
||||
throw new ApiError(envelope.error ?? 'Unknown API error', res.status);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { IconAlert } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -66,7 +67,7 @@
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors active:animate-press"
|
||||
onclick={oncancel}
|
||||
>
|
||||
Cancel
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||
import { IconPlay, IconStop, IconRestart, IconTrash, IconExternalLink } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
import { t } from '$lib/i18n';
|
||||
import * as api from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
@@ -148,7 +149,7 @@
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmAction !== null}
|
||||
title="{confirmAction ? confirmAction.charAt(0).toUpperCase() + confirmAction.slice(1) : ''} Instance"
|
||||
title={confirmAction ? $t(`confirm.${confirmAction}Instance`) : ''}
|
||||
message={confirmAction ? $t(`instance.${confirmAction}Confirm`) : ''}
|
||||
confirmLabel={confirmAction ? confirmAction.charAt(0).toUpperCase() + confirmAction.slice(1) : ''}
|
||||
confirmVariant={confirmAction === 'remove' ? 'danger' : 'primary'}
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
<span class="ml-auto rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-tertiary)]">
|
||||
{totalCount} {totalCount === 1 ? 'instance' : 'instances'}
|
||||
{totalCount} {totalCount === 1 ? $t('common.instance') : $t('common.instances')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{$t('projectDetail.noInstancesRunning')}</span>
|
||||
|
||||
@@ -316,7 +316,9 @@
|
||||
"stop": "Stop",
|
||||
"start": "Start",
|
||||
"restart": "Restart",
|
||||
"remove": "Remove"
|
||||
"remove": "Remove",
|
||||
"instance": "instance",
|
||||
"instances": "instances"
|
||||
},
|
||||
"instance": {
|
||||
"stopConfirm": "This will stop the running container. The instance can be started again later.",
|
||||
@@ -339,6 +341,24 @@
|
||||
"noUsers": "No users",
|
||||
"noUsersDesc": "Add local users to manage access."
|
||||
},
|
||||
"validation": {
|
||||
"required": "{field} is required",
|
||||
"invalidUrl": "Invalid URL format",
|
||||
"invalidDomain": "Invalid domain format",
|
||||
"invalidIp": "Invalid IP format",
|
||||
"invalidEmail": "Invalid email format",
|
||||
"invalidPort": "Port must be between 1 and 65535",
|
||||
"invalidPollingInterval": "Polling interval must be between 10 and 86400 seconds",
|
||||
"invalidProjectName": "Only lowercase letters, numbers, and hyphens allowed",
|
||||
"requiredWhenUpdating": "{field} is required when updating credentials",
|
||||
"requiredForNew": "{field} is required for new registries"
|
||||
},
|
||||
"confirm": {
|
||||
"stopInstance": "Stop Instance",
|
||||
"startInstance": "Start Instance",
|
||||
"restartInstance": "Restart Instance",
|
||||
"removeInstance": "Remove Instance"
|
||||
},
|
||||
"theme": {
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
|
||||
@@ -316,7 +316,9 @@
|
||||
"stop": "Остановить",
|
||||
"start": "Запустить",
|
||||
"restart": "Перезапустить",
|
||||
"remove": "Удалить"
|
||||
"remove": "Удалить",
|
||||
"instance": "экземпляр",
|
||||
"instances": "экземпляров"
|
||||
},
|
||||
"instance": {
|
||||
"stopConfirm": "Контейнер будет остановлен. Экземпляр можно будет запустить снова позже.",
|
||||
@@ -339,6 +341,24 @@
|
||||
"noUsers": "Нет пользователей",
|
||||
"noUsersDesc": "Добавьте локальных пользователей для управления доступом."
|
||||
},
|
||||
"validation": {
|
||||
"required": "Поле {field} обязательно",
|
||||
"invalidUrl": "Неверный формат URL",
|
||||
"invalidDomain": "Неверный формат домена",
|
||||
"invalidIp": "Неверный формат IP",
|
||||
"invalidEmail": "Неверный формат email",
|
||||
"invalidPort": "Порт должен быть от 1 до 65535",
|
||||
"invalidPollingInterval": "Интервал опроса должен быть от 10 до 86400 секунд",
|
||||
"invalidProjectName": "Допускаются только строчные буквы, цифры и дефисы",
|
||||
"requiredWhenUpdating": "Поле {field} обязательно при обновлении учётных данных",
|
||||
"requiredForNew": "Поле {field} обязательно для новых реестров"
|
||||
},
|
||||
"confirm": {
|
||||
"stopInstance": "Остановить экземпляр",
|
||||
"startInstance": "Запустить экземпляр",
|
||||
"restartInstance": "Перезапустить экземпляр",
|
||||
"removeInstance": "Удалить экземпляр"
|
||||
},
|
||||
"theme": {
|
||||
"light": "Светлая",
|
||||
"dark": "Тёмная",
|
||||
|
||||
@@ -22,24 +22,24 @@
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
function validateImageUrl(url: string): string {
|
||||
if (!url.trim()) return 'Image URL is required';
|
||||
if (!url.trim()) return $t('validation.required', { field: 'Image URL' });
|
||||
if (!/^[a-zA-Z0-9._\-/]+:[a-zA-Z0-9._\-]+$/.test(url.trim())) {
|
||||
return 'Invalid image URL format (e.g., registry.example.com/org/app:tag)';
|
||||
return $t('validation.invalidUrl');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function validatePort(value: string): string {
|
||||
if (!value.trim()) return 'Port is required';
|
||||
if (!value.trim()) return $t('validation.required', { field: 'Port' });
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < 1 || num > 65535) return 'Port must be between 1 and 65535';
|
||||
if (isNaN(num) || num < 1 || num > 65535) return $t('validation.invalidPort');
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateProjectName(value: string): string {
|
||||
if (!value.trim()) return 'Project name is required';
|
||||
if (!value.trim()) return $t('validation.required', { field: 'Project name' });
|
||||
if (value.trim().length > 1 && !/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(value.trim())) {
|
||||
return 'Must be lowercase alphanumeric with hyphens (e.g., my-app)';
|
||||
return $t('validation.invalidProjectName');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -16,11 +16,26 @@
|
||||
applyTheme($resolvedTheme);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
const urlToken = $page.url.searchParams.get('token');
|
||||
if (urlToken) {
|
||||
localStorage.setItem('auth_token', urlToken);
|
||||
goto('/');
|
||||
// Validate the token against the backend before trusting it.
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${urlToken}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
localStorage.setItem('auth_token', urlToken);
|
||||
// Remove token from URL to prevent leakage via history/referrer.
|
||||
history.replaceState(null, '', '/login');
|
||||
goto('/');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Token validation failed — fall through to login form.
|
||||
}
|
||||
// Remove invalid token from URL.
|
||||
history.replaceState(null, '', '/login');
|
||||
}
|
||||
const existingToken = localStorage.getItem('auth_token');
|
||||
if (existingToken) {
|
||||
|
||||
@@ -22,27 +22,27 @@
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
function validateDomain(value: string): string {
|
||||
if (!value.trim()) return 'Domain is required';
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(value.trim())) return 'Invalid domain format';
|
||||
if (!value.trim()) return $t('validation.required', { field: 'Domain' });
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(value.trim())) return $t('validation.invalidDomain');
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateIp(value: string): string {
|
||||
if (!value.trim()) return '';
|
||||
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(value.trim())) return 'Invalid IP address format';
|
||||
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(value.trim())) return $t('validation.invalidIp');
|
||||
return '';
|
||||
}
|
||||
|
||||
function validatePollingInterval(value: string): string {
|
||||
if (!value.trim()) return '';
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < 10 || num > 86400) return 'Polling interval must be between 10 and 86400 seconds';
|
||||
if (isNaN(num) || num < 10 || num > 86400) return $t('validation.invalidPollingInterval');
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateUrl(value: string): string {
|
||||
if (!value.trim()) return '';
|
||||
try { new URL(value.trim()); return ''; } catch { return 'Invalid URL format'; }
|
||||
try { new URL(value.trim()); return ''; } catch { return $t('validation.invalidUrl'); }
|
||||
}
|
||||
|
||||
function validateAll(): boolean {
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
|
||||
function validateNpmForm(): boolean {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!npmUrl.trim()) { newErrors.npmUrl = 'NPM URL is required'; } else { try { new URL(npmUrl.trim()); } catch { newErrors.npmUrl = 'Invalid URL format'; } }
|
||||
if (!npmEmail.trim()) { newErrors.npmEmail = 'Email is required'; } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(npmEmail.trim())) { newErrors.npmEmail = 'Invalid email format'; }
|
||||
if (editingNpm && !npmPassword.trim()) { newErrors.npmPassword = 'Password is required when updating credentials'; }
|
||||
if (!npmUrl.trim()) { newErrors.npmUrl = $t('validation.required', { field: 'NPM URL' }); } else { try { new URL(npmUrl.trim()); } catch { newErrors.npmUrl = $t('validation.invalidUrl'); } }
|
||||
if (!npmEmail.trim()) { newErrors.npmEmail = $t('validation.required', { field: 'Email' }); } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(npmEmail.trim())) { newErrors.npmEmail = $t('validation.invalidEmail'); }
|
||||
if (editingNpm && !npmPassword.trim()) { newErrors.npmPassword = $t('validation.requiredWhenUpdating', { field: 'Password' }); }
|
||||
errors = newErrors;
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
|
||||
function validateForm(): boolean {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formName.trim()) newErrors.name = 'Name is required';
|
||||
if (!formUrl.trim()) { newErrors.url = 'URL is required'; } else { try { new URL(formUrl.trim()); } catch { newErrors.url = 'Invalid URL format'; } }
|
||||
if (!formToken.trim() && !editingId) newErrors.token = 'Token is required for new registries';
|
||||
if (!formName.trim()) newErrors.name = $t('validation.required', { field: 'Name' });
|
||||
if (!formUrl.trim()) { newErrors.url = $t('validation.required', { field: 'URL' }); } else { try { new URL(formUrl.trim()); } catch { newErrors.url = $t('validation.invalidUrl'); } }
|
||||
if (!formToken.trim() && !editingId) newErrors.token = $t('validation.requiredForNew', { field: 'Token' });
|
||||
errors = newErrors;
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user