feat(f6.2): add per-act representative signing and user smtp
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 1m25s
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m16s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 1m25s
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m16s
This commit is contained in:
@@ -9,6 +9,7 @@ import { CompanySignaturePage } from '../pages/CompanySignaturePage';
|
||||
import { DashboardPage } from '../pages/DashboardPage';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import { NewUserPage } from '../pages/NewUserPage';
|
||||
import { MyProfilePage } from '../pages/MyProfilePage';
|
||||
import { RolesPage } from '../pages/RolesPage';
|
||||
import { AccessDeniedPage, NotFoundPage } from '../pages/SystemPages';
|
||||
import { UserDetailPage } from '../pages/UserDetailPage';
|
||||
@@ -45,6 +46,7 @@ export function App() {
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||
<Route element={<OperationalContextProvider><AppLayout /></OperationalContextProvider>}>
|
||||
<Route path="/mi-perfil" element={<MyProfilePage />} />
|
||||
<Route element={<PermissionRoute permission="dashboard.read" />}><Route index element={<DashboardPage />} /></Route>
|
||||
<Route element={<PermissionRoute permission="assets.read" />}><Route path="/mapa" element={<MapPage />} /></Route>
|
||||
<Route element={<PermissionRoute permission="assets.read" />}>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.23.0-2';
|
||||
export const APP_PHASE = 'F6.1 · Contexto operativo Área–Operadora consolidado';
|
||||
export const APP_VERSION = '0.23.0-3';
|
||||
export const APP_PHASE = 'F6.2 · Firma por Acta y correo de usuario';
|
||||
|
||||
@@ -100,11 +100,13 @@ export function AppLayout() {
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-user">
|
||||
<div className="user-avatar">{user?.firstName?.[0]}{user?.lastName?.[0]}</div>
|
||||
<div className="user-copy">
|
||||
<strong>{user?.firstName} {user?.lastName}</strong>
|
||||
<small>{user?.roles.join(' · ') || user?.username}</small>
|
||||
</div>
|
||||
<NavLink to="/mi-perfil" onClick={close} className="sidebar-user-profile" title="Abrir Mi perfil">
|
||||
<div className="user-avatar">{user?.firstName?.[0]}{user?.lastName?.[0]}</div>
|
||||
<div className="user-copy">
|
||||
<strong>{user?.firstName} {user?.lastName}</strong>
|
||||
<small>Mi perfil · {user?.roles.join(' · ') || user?.username}</small>
|
||||
</div>
|
||||
</NavLink>
|
||||
<button className="icon-button dark" onClick={handleLogout} title="Cerrar sesión" aria-label="Cerrar sesión">
|
||||
<Icon name="logout" />
|
||||
</button>
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface AdministrativeUser {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
roles: RoleSummary[];
|
||||
smtpMode: 'SYSTEM' | 'CUSTOM';
|
||||
}
|
||||
|
||||
export interface Permission { id: string; code: string; description: string }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { apiRequest } from './api';
|
||||
import type { AdministrativeUserProfile } from './userProfileApi';
|
||||
|
||||
export type UserSmtpMode = 'SYSTEM' | 'CUSTOM';
|
||||
export type UserSmtpSecurityMode = 'NONE' | 'STARTTLS' | 'TLS';
|
||||
|
||||
export interface UserSmtpCustomSettings {
|
||||
host: string;
|
||||
port: number;
|
||||
securityMode: UserSmtpSecurityMode;
|
||||
username: string;
|
||||
hasPassword: boolean;
|
||||
fromName: string;
|
||||
fromEmail: string;
|
||||
enabled: boolean;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface UserSmtpSettings {
|
||||
mode: UserSmtpMode;
|
||||
email: string;
|
||||
generalConfigured: boolean;
|
||||
custom: UserSmtpCustomSettings | null;
|
||||
}
|
||||
|
||||
export interface UserSmtpSettingsInput {
|
||||
mode: UserSmtpMode;
|
||||
host?: string;
|
||||
port?: number;
|
||||
securityMode?: UserSmtpSecurityMode;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
fromName?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function getSelfProfile() {
|
||||
return apiRequest<AdministrativeUserProfile>('/users/self/profile');
|
||||
}
|
||||
|
||||
export function updateSelfProfile(input: {
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
jobTitle?: string | null;
|
||||
}) {
|
||||
return apiRequest<AdministrativeUserProfile>('/users/self/profile', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function getSelfSmtpSettings() {
|
||||
return apiRequest<UserSmtpSettings>('/users/self/smtp');
|
||||
}
|
||||
|
||||
export function saveSelfSmtpSettings(input: UserSmtpSettingsInput) {
|
||||
return apiRequest<UserSmtpSettings>('/users/self/smtp', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function testSelfSmtpSettings() {
|
||||
return apiRequest<{ ok: boolean; recipient: string; messageId: string }>('/users/self/smtp/test', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface UserProfileInput {
|
||||
|
||||
export interface CreateUserProfileInput extends UserProfileInput {
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
password: string;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { initials } from '../lib/format';
|
||||
import {
|
||||
getSelfProfile,
|
||||
getSelfSmtpSettings,
|
||||
saveSelfSmtpSettings,
|
||||
testSelfSmtpSettings,
|
||||
updateSelfProfile,
|
||||
} from '../lib/myProfileApi';
|
||||
import type {
|
||||
UserSmtpMode,
|
||||
UserSmtpSecurityMode,
|
||||
UserSmtpSettings,
|
||||
} from '../lib/myProfileApi';
|
||||
import type { AdministrativeUserProfile } from '../lib/userProfileApi';
|
||||
|
||||
export function MyProfilePage() {
|
||||
const { user } = useAuth();
|
||||
const [profile, setProfile] = useState<AdministrativeUserProfile | null>(null);
|
||||
const [smtp, setSmtp] = useState<UserSmtpSettings | null>(null);
|
||||
const [mode, setMode] = useState<UserSmtpMode>('SYSTEM');
|
||||
const [host, setHost] = useState('');
|
||||
const [port, setPort] = useState(587);
|
||||
const [securityMode, setSecurityMode] = useState<UserSmtpSecurityMode>('STARTTLS');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [fromName, setFromName] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const applySmtp = (settings: UserSmtpSettings) => {
|
||||
setSmtp(settings);
|
||||
setMode(settings.mode);
|
||||
setHost(settings.custom?.host ?? '');
|
||||
setPort(settings.custom?.port ?? 587);
|
||||
setSecurityMode(settings.custom?.securityMode ?? 'STARTTLS');
|
||||
setUsername(settings.custom?.username ?? '');
|
||||
setFromName(settings.custom?.fromName ?? '');
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [loadedProfile, loadedSmtp] = await Promise.all([
|
||||
getSelfProfile(),
|
||||
getSelfSmtpSettings(),
|
||||
]);
|
||||
setProfile(loadedProfile);
|
||||
applySmtp(loadedSmtp);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const saveProfile = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!profile) return;
|
||||
setError(''); setSuccess(''); setSaving('profile');
|
||||
try {
|
||||
const form = new FormData(event.currentTarget);
|
||||
const updated = await updateSelfProfile({
|
||||
email: String(form.get('email') ?? '').trim(),
|
||||
phone: String(form.get('phone') ?? '').trim() || null,
|
||||
jobTitle: String(form.get('jobTitle') ?? '').trim() || null,
|
||||
});
|
||||
setProfile(updated);
|
||||
setSuccess('Perfil actualizado correctamente.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving('');
|
||||
}
|
||||
};
|
||||
|
||||
const saveSmtp = async () => {
|
||||
setError(''); setSuccess(''); setSaving('smtp');
|
||||
try {
|
||||
const updated = await saveSelfSmtpSettings(mode === 'SYSTEM' ? {
|
||||
mode: 'SYSTEM',
|
||||
} : {
|
||||
mode: 'CUSTOM', host: host.trim(), port, securityMode,
|
||||
username: username.trim() || null,
|
||||
password: password || undefined,
|
||||
fromName: fromName.trim() || null,
|
||||
enabled: true,
|
||||
});
|
||||
applySmtp(updated);
|
||||
setSuccess(mode === 'SYSTEM' ? 'Usarás el SMTP general.' : 'SMTP propio guardado.');
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving('');
|
||||
}
|
||||
};
|
||||
|
||||
const testSmtp = async () => {
|
||||
setError(''); setSuccess(''); setSaving('test');
|
||||
try {
|
||||
const result = await testSelfSmtpSettings();
|
||||
setSuccess(`Correo de prueba enviado a ${result.recipient}.`);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving('');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando tu perfil…" />;
|
||||
if (!profile || !smtp) return <Alert>{error || 'No se pudo cargar el perfil.'}</Alert>;
|
||||
|
||||
return <section>
|
||||
<div className="page-heading user-heading">
|
||||
<div className="profile-title">
|
||||
<span className="profile-avatar">{initials(profile.firstName, profile.lastName)}</span>
|
||||
<div>
|
||||
<span className="eyebrow">MI PERFIL</span>
|
||||
<h1>{profile.firstName} {profile.lastName}</h1>
|
||||
<p>@{profile.username} · {profile.jobTitle || 'Función sin informar'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
<div className="detail-grid">
|
||||
<form className="panel form-panel" onSubmit={saveProfile}>
|
||||
<div className="panel-heading">
|
||||
<div><span className="eyebrow">CONTACTO</span><h2>Datos del usuario</h2></div>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Nombres y apellidos</span><input value={`${profile.firstName} ${profile.lastName}`} disabled /></label>
|
||||
<label className="field"><span>Email institucional *</span><input name="email" type="email" defaultValue={profile.email ?? ''} required /></label>
|
||||
<label className="field"><span>Teléfono</span><input name="phone" type="tel" defaultValue={profile.phone ?? ''} /></label>
|
||||
<label className="field"><span>Cargo / función</span><input name="jobTitle" defaultValue={profile.jobTitle ?? ''} /></label>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button className="button primary" type="submit" disabled={saving === 'profile'}>
|
||||
{saving === 'profile' ? 'Guardando…' : 'Guardar perfil'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="panel form-panel">
|
||||
<div className="panel-heading">
|
||||
<div><span className="eyebrow">CORREO SALIENTE</span><h2>SMTP personal</h2></div>
|
||||
</div>
|
||||
<p className="muted-copy">Por defecto, tus correos salen mediante el SMTP general. Podés cambiar a una cuenta SMTP propia con un clic.</p>
|
||||
<div className="choice-grid">
|
||||
<label className={`choice-card ${mode === 'SYSTEM' ? 'selected' : ''}`}>
|
||||
<input type="radio" name="smtpMode" checked={mode === 'SYSTEM'} onChange={() => setMode('SYSTEM')} />
|
||||
<span><strong>Usar SMTP general</strong><small>{smtp.generalConfigured ? 'Configurado y disponible' : 'Todavía no configurado por el administrador'}</small></span>
|
||||
<span className="icon">✓</span>
|
||||
</label>
|
||||
<label className={`choice-card ${mode === 'CUSTOM' ? 'selected' : ''}`}>
|
||||
<input type="radio" name="smtpMode" checked={mode === 'CUSTOM'} onChange={() => setMode('CUSTOM')} />
|
||||
<span><strong>Usar SMTP propio</strong><small>Los envíos saldrán con tu cuenta {profile.email}</small></span>
|
||||
<span className="icon">✓</span>
|
||||
</label>
|
||||
</div>
|
||||
{mode === 'CUSTOM' && <>
|
||||
<div className="form-grid" style={{ marginTop: 16 }}>
|
||||
<label className="field"><span>Servidor SMTP *</span><input value={host} onChange={(event) => setHost(event.target.value)} placeholder="smtp.ejemplo.com" /></label>
|
||||
<label className="field"><span>Puerto *</span><input type="number" min={1} max={65535} value={port} onChange={(event) => setPort(Number(event.target.value))} /></label>
|
||||
<label className="field"><span>Seguridad *</span><select value={securityMode} onChange={(event) => setSecurityMode(event.target.value as UserSmtpSecurityMode)}><option value="STARTTLS">STARTTLS</option><option value="TLS">TLS</option><option value="NONE">Sin cifrado</option></select></label>
|
||||
<label className="field"><span>Usuario SMTP</span><input value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" /></label>
|
||||
<label className="field"><span>Contraseña SMTP</span><input type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="new-password" placeholder={smtp.custom?.hasPassword ? 'Dejar vacío para conservarla' : 'Contraseña SMTP'} /></label>
|
||||
<label className="field"><span>Nombre del remitente</span><input value={fromName} onChange={(event) => setFromName(event.target.value)} placeholder={`${profile.firstName} ${profile.lastName}`} /></label>
|
||||
</div>
|
||||
<p className="muted-copy">La contraseña se cifra en el servidor y nunca vuelve a mostrarse. El remitente será <strong>{profile.email}</strong>.</p>
|
||||
</>}
|
||||
<div className="form-actions wrap-actions">
|
||||
<button className="button primary" type="button" onClick={saveSmtp} disabled={saving === 'smtp' || (mode === 'CUSTOM' && (!host.trim() || !port))}>{saving === 'smtp' ? 'Guardando…' : 'Guardar configuración'}</button>
|
||||
<button className="button secondary" type="button" onClick={testSmtp} disabled={Boolean(saving)}>{saving === 'test' ? 'Enviando…' : 'Enviar correo de prueba'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel" style={{ marginTop: 16 }}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">SEGURIDAD</span><h2>Cuenta</h2></div></div>
|
||||
<p>Tu email es obligatorio para operar en DH Inspección. La contraseña SMTP nunca se muestra ni se incluye en auditorías.</p>
|
||||
<p className="muted-copy">Usuario autenticado: @{user?.username ?? profile.username}</p>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -30,15 +30,15 @@ export function NewUserPage() {
|
||||
setError('');
|
||||
const data = new FormData(event.currentTarget);
|
||||
const email = String(data.get('email') ?? '').trim();
|
||||
if (inspectorSelected && !email) {
|
||||
setError('El email es obligatorio para usuarios con rol Inspector porque allí recibirán la documentación de sus inspecciones.');
|
||||
if (!email) {
|
||||
setError('El email es obligatorio para todos los usuarios de Hidrocarburos.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const created = await createUserProfile({
|
||||
username: String(data.get('username')),
|
||||
email: email || null,
|
||||
email,
|
||||
dni: String(data.get('dni') ?? '').trim() || null,
|
||||
phone: String(data.get('phone') ?? '').trim() || null,
|
||||
jobTitle: String(data.get('jobTitle') ?? '').trim() || null,
|
||||
@@ -75,10 +75,10 @@ export function NewUserPage() {
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div><h2>Acceso y contacto</h2><p className="section-copy">El email del Inspector se utiliza también como destinatario de la documentación al cerrar la inspección.</p></div>
|
||||
<div><h2>Acceso y contacto</h2><p className="section-copy">Cada usuario debe tener un email institucional. Los Inspectores también lo utilizan en la entrega documental de sus Actas.</p></div>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Usuario <em>obligatorio</em></span><input name="username" required minLength={3} maxLength={80} pattern="[a-zA-Z][a-zA-Z0-9._-]+" /></label>
|
||||
<label className="field"><span>Email {inspectorSelected ? <em>obligatorio para Inspector</em> : <em>recomendado</em>}</span><input name="email" type="email" maxLength={320} required={inspectorSelected} /></label>
|
||||
<label className="field"><span>Email <em>obligatorio</em></span><input name="email" type="email" maxLength={320} required /></label>
|
||||
</div>
|
||||
{inspectorSelected && <div className="temporal-notice"><Icon name="mail" /><p><strong>Inspector:</strong> este email recibirá copia de las Actas/Informe correspondientes al cierre de la inspección.</p></div>}
|
||||
</div>
|
||||
|
||||
@@ -57,8 +57,8 @@ export function UserDetailPage() {
|
||||
setError(''); setSuccess('');
|
||||
const data = new FormData(event.currentTarget);
|
||||
const email = String(data.get('email') ?? '').trim();
|
||||
if (inspectorSelected && !email) {
|
||||
setError('El email es obligatorio para un Inspector porque allí recibe la documentación de sus inspecciones.');
|
||||
if (!email) {
|
||||
setError('El email es obligatorio para todos los usuarios de Hidrocarburos.');
|
||||
return;
|
||||
}
|
||||
setSaving('profile');
|
||||
@@ -139,6 +139,7 @@ export function UserDetailPage() {
|
||||
<div className="detail-grid">
|
||||
<form className="panel form-panel" onSubmit={saveProfile}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">PERFIL</span><h2>Datos personales y contacto</h2></div><span className={`status-badge ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'}</span></div>
|
||||
<p className="muted-copy">Correo saliente: <strong>{user.smtpMode === 'CUSTOM' ? 'SMTP propio' : 'SMTP general'}</strong>. Cada usuario administra su modalidad desde Mi perfil.</p>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Nombre</span><input name="firstName" defaultValue={user.firstName} required disabled={!canUpdate} /></label>
|
||||
<label className="field"><span>Apellido</span><input name="lastName" defaultValue={user.lastName} required disabled={!canUpdate} /></label>
|
||||
@@ -147,7 +148,7 @@ export function UserDetailPage() {
|
||||
<label className="field"><span>Cargo / función</span><input name="jobTitle" defaultValue={user.jobTitle ?? ''} maxLength={160} disabled={!canUpdate} /></label>
|
||||
<label className="field"><span>Legajo / matrícula</span><input name="employeeNumber" defaultValue={user.employeeNumber ?? ''} maxLength={80} disabled={!canUpdate} /></label>
|
||||
<label className="field"><span>Usuario</span><input name="username" defaultValue={user.username} required disabled={!canUpdate} /></label>
|
||||
<label className="field"><span>Email {inspectorSelected && <em>obligatorio para Inspector</em>}</span><input name="email" type="email" defaultValue={user.email ?? ''} required={inspectorSelected} disabled={!canUpdate} /></label>
|
||||
<label className="field"><span>Email <em>obligatorio</em></span><input name="email" type="email" defaultValue={user.email ?? ''} required disabled={!canUpdate} /></label>
|
||||
</div>
|
||||
{inspectorSelected && <div className="temporal-notice"><Icon name="mail" /><p><strong>Destinatario del Inspector:</strong> al finalizar una inspección, la documentación se enviará también a <strong>{user.email || 'este email cuando lo completes'}</strong>.</p></div>}
|
||||
<div className="metadata-grid"><div><small>Último acceso</small><strong>{formatDate(user.lastLoginAt)}</strong></div><div><small>Último cambio de clave</small><strong>{formatDate(user.passwordChangedAt)}</strong></div><div><small>Intentos fallidos</small><strong>{user.failedLoginAttempts}</strong></div><div><small>Bloqueado hasta</small><strong>{formatDate(user.lockedUntil)}</strong></div></div>
|
||||
|
||||
@@ -46,6 +46,8 @@ a { color: inherit; }
|
||||
.nav-link.active .icon { color: #73a0ff; }
|
||||
|
||||
.sidebar-user { display: grid; grid-template-columns: auto minmax(0,1fr) auto; gap: 10px; align-items: center; margin-top: auto; padding: 16px 6px 0; border-top: 1px solid rgba(255,255,255,.09); }
|
||||
.sidebar-user-profile { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-width:0; color:inherit; text-decoration:none; border-radius:10px; padding:4px; }
|
||||
.sidebar-user-profile:hover { background:rgba(255,255,255,.06); }
|
||||
.user-avatar, .mini-avatar, .profile-avatar { display: grid; place-items: center; color: #2759c3; background: #dce8ff; font-weight: 800; }
|
||||
.user-avatar { width: 34px; height: 34px; border-radius: 50%; font-size: 11px; }
|
||||
.user-copy { min-width: 0; }
|
||||
|
||||
Reference in New Issue
Block a user