feat(memberController, memberRoutes, MembersView): implement Click-TT player registration feature
- Added a new endpoint for Click-TT player registration in memberController, allowing submission of existing player applications. - Integrated the new endpoint into memberRoutes for handling requests. - Updated MembersView to include a button for initiating Click-TT registration, with user confirmation and loading state management. - Enhanced UI feedback for registration status, improving user experience during the application process.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import MemberService from "../services/memberService.js";
|
||||
import MemberTransferService from "../services/memberTransferService.js";
|
||||
import clickTtPlayerRegistrationService from "../services/clickTtPlayerRegistrationService.js";
|
||||
import { emitMemberChanged } from '../services/socketService.js';
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
@@ -207,6 +208,28 @@ const quickDeactivateMember = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
const requestClickTtPlayerRegistration = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const userId = req.user?.id;
|
||||
const result = await clickTtPlayerRegistrationService.submitExistingPlayerApplication({
|
||||
userToken,
|
||||
userId,
|
||||
clubId,
|
||||
memberId
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[requestClickTtPlayerRegistration] - Error:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || 'Click-TT-Antrag konnte nicht eingereicht werden',
|
||||
trace: Array.isArray(error.trace) ? error.trace : []
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const transferMembers = async (req, res) => {
|
||||
try {
|
||||
const { id: clubId } = req.params;
|
||||
@@ -251,7 +274,8 @@ export {
|
||||
quickUpdateTestMembership,
|
||||
quickUpdateMemberFormHandedOver,
|
||||
quickDeactivateMember,
|
||||
requestClickTtPlayerRegistration,
|
||||
deleteMemberImage,
|
||||
setPrimaryMemberImage,
|
||||
generateMemberGallery
|
||||
};
|
||||
};
|
||||
|
||||
@@ -336,7 +336,12 @@ function injectProxyNavigationScript(html, proxyBaseUrl, pageBaseUrl, sid) {
|
||||
"var submitControl=event.target&&event.target.closest?event.target.closest('button, input[type=\"submit\"], input[type=\"image\"]'):null;",
|
||||
'if(submitControl){',
|
||||
'lastSubmitter=submitControl;',
|
||||
"try{console.log('[ClickTT Proxy] submit control click',{name:submitControl.name||null,value:submitControl.value||null,type:submitControl.type||submitControl.tagName,formAction:submitControl.getAttribute?submitControl.getAttribute('formaction'):null,text:(submitControl.textContent||'').trim().slice(0,120)});}catch(e){}",
|
||||
"try{console.log('[ClickTT Proxy] submit control click',{name:submitControl.name||null,value:submitControl.value||null,type:submitControl.type||submitControl.tagName,formAction:submitControl.getAttribute?submitControl.getAttribute('formaction'):null,onclick:submitControl.getAttribute?submitControl.getAttribute('onclick'):null,text:(submitControl.textContent||'').trim().slice(0,120)});}catch(e){}",
|
||||
'if(!shouldAllowInlineConfirm(submitControl)){',
|
||||
'event.preventDefault();',
|
||||
'event.stopPropagation();',
|
||||
'return;',
|
||||
'}',
|
||||
'}',
|
||||
'if(!anchor||event.defaultPrevented)return;',
|
||||
'if(!shouldAllowInlineConfirm(anchor)){',
|
||||
@@ -353,6 +358,16 @@ function injectProxyNavigationScript(html, proxyBaseUrl, pageBaseUrl, sid) {
|
||||
'var form=event.target;',
|
||||
"if(!form||!form.tagName||form.tagName.toLowerCase()!=='form')return;",
|
||||
'var submitter=event.submitter||lastSubmitter||null;',
|
||||
'if(submitter&&!shouldAllowInlineConfirm(submitter)){',
|
||||
'event.preventDefault();',
|
||||
'event.stopPropagation();',
|
||||
'return;',
|
||||
'}',
|
||||
'if(!shouldAllowInlineConfirm(form)){',
|
||||
'event.preventDefault();',
|
||||
'event.stopPropagation();',
|
||||
'return;',
|
||||
'}',
|
||||
'var targetUrl=getSubmitTarget(form,submitter);',
|
||||
'if(!targetUrl||!shouldProxyUrl(targetUrl))return;',
|
||||
'if(submitter&&submitter.form===form&&submitter.name){',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
quickUpdateTestMembership,
|
||||
quickUpdateMemberFormHandedOver,
|
||||
quickDeactivateMember,
|
||||
requestClickTtPlayerRegistration,
|
||||
deleteMemberImage,
|
||||
setPrimaryMemberImage,
|
||||
generateMemberGallery
|
||||
@@ -39,5 +40,6 @@ router.post('/transfer/:id', authenticate, authorize('members', 'write'), transf
|
||||
router.post('/quick-update-test-membership/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateTestMembership);
|
||||
router.post('/quick-update-member-form/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateMemberFormHandedOver);
|
||||
router.post('/quick-deactivate/:clubId/:memberId', authenticate, authorize('members', 'write'), quickDeactivateMember);
|
||||
router.post('/clicktt-registration/:clubId/:memberId', authenticate, authorize('members', 'write'), requestClickTtPlayerRegistration);
|
||||
|
||||
export default router;
|
||||
|
||||
347
backend/services/clickTtPlayerRegistrationService.js
Normal file
347
backend/services/clickTtPlayerRegistrationService.js
Normal file
@@ -0,0 +1,347 @@
|
||||
import { chromium } from 'playwright';
|
||||
import Member from '../models/Member.js';
|
||||
import MyTischtennis from '../models/MyTischtennis.js';
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
const CLICKTT_ENTRY_URL = 'https://httv.click-tt.de/';
|
||||
const TRACE_LIMIT = 250;
|
||||
|
||||
function formatGermanDate(value) {
|
||||
if (!value) return '';
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = String(date.getFullYear());
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function sanitizePageText(text) {
|
||||
return String(text || '').replace(/\s+/g, ' ').trim().slice(0, 500);
|
||||
}
|
||||
|
||||
function sanitizePayload(payload) {
|
||||
return String(payload || '')
|
||||
.replace(/(password|passwd|pwd|token|secret|captcha)=([^&\s]+)/gi, '$1=[redacted]')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 1500);
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
class ClickTtPlayerRegistrationService {
|
||||
async submitExistingPlayerApplication({ userToken, userId, clubId, memberId }) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const member = await Member.findOne({
|
||||
where: { id: memberId, clubId },
|
||||
include: [{ association: 'contacts', required: false }]
|
||||
});
|
||||
if (!member) {
|
||||
throw new HttpError('Mitglied nicht gefunden', 404);
|
||||
}
|
||||
|
||||
const account = await MyTischtennis.findOne({ where: { userId } });
|
||||
if (!account?.email) {
|
||||
throw new HttpError('Kein myTischtennis-/TTDE-Account für diesen Benutzer hinterlegt', 400);
|
||||
}
|
||||
|
||||
const password = account.getPassword?.() || null;
|
||||
const savedStorageState = account.playwrightStorageState ?? null;
|
||||
if (!savedStorageState && !password) {
|
||||
throw new HttpError('Für die Click-TT-Automatisierung wird ein gespeichertes myTischtennis-Passwort oder eine gültige Browser-Session benötigt', 400);
|
||||
}
|
||||
|
||||
const email = account.email;
|
||||
const memberJson = member.toJSON();
|
||||
const primaryEmail = this._getPrimaryContactValue(memberJson, 'email') || memberJson.email || '';
|
||||
|
||||
if (!primaryEmail) {
|
||||
throw new HttpError('Für den Antrag wird eine E-Mail-Adresse beim Mitglied benötigt', 400);
|
||||
}
|
||||
|
||||
let browser = null;
|
||||
let context = null;
|
||||
let page = null;
|
||||
const trace = [];
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage']
|
||||
});
|
||||
context = savedStorageState
|
||||
? await browser.newContext({ storageState: savedStorageState })
|
||||
: await browser.newContext();
|
||||
page = await context.newPage();
|
||||
this._attachNetworkLogging(page, trace);
|
||||
|
||||
await this._openAuthenticatedClickTt(page, { email, password, trace });
|
||||
await this._clickByText(page, 'Spielberechtigungen beantragen', trace);
|
||||
await this._fillSearchForm(page, memberJson);
|
||||
await this._clickByText(page, 'Personen suchen', trace);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await this._abortIfConfirmFlow(page);
|
||||
await this._fillApplicationForm(page, memberJson, primaryEmail);
|
||||
await this._clickByText(page, 'Weiter >>', trace);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await this._clickByText(page, 'Speichern', trace);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await this._clickByText(page, 'Einreichen', trace);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const finalText = sanitizePageText(await page.locator('body').innerText());
|
||||
const storageState = await context.storageState();
|
||||
account.playwrightStorageState = storageState;
|
||||
await account.save({ fields: ['playwrightStorageState'] });
|
||||
this._trace(trace, 'final', {
|
||||
url: page.url(),
|
||||
text: finalText
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Spielberechtigung fuer ${memberJson.firstName} ${memberJson.lastName} wurde in click-TT eingereicht.`,
|
||||
finalUrl: page.url(),
|
||||
finalText,
|
||||
trace
|
||||
};
|
||||
} catch (error) {
|
||||
let diagnostics = {};
|
||||
try {
|
||||
diagnostics = {
|
||||
url: page?.url?.() || null,
|
||||
text: sanitizePageText(await page?.locator?.('body')?.innerText?.()),
|
||||
traceTail: trace.slice(-25)
|
||||
};
|
||||
} catch (_err) {
|
||||
diagnostics = {};
|
||||
}
|
||||
|
||||
this._trace(trace, 'error', {
|
||||
message: error?.message || String(error),
|
||||
url: diagnostics.url || null
|
||||
});
|
||||
const message = error instanceof HttpError
|
||||
? error.message
|
||||
: `Click-TT-Automatisierung fehlgeschlagen: ${error.message || error}`;
|
||||
const wrappedError = new HttpError(`${message}${diagnostics.url ? ` (Seite: ${diagnostics.url})` : ''}${diagnostics.text ? ` - ${diagnostics.text}` : ''}`, error.statusCode || error.status || 500);
|
||||
wrappedError.trace = diagnostics.traceTail || [];
|
||||
throw wrappedError;
|
||||
} finally {
|
||||
if (context) {
|
||||
try {
|
||||
await context.close();
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (browser) {
|
||||
try {
|
||||
await browser.close();
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_getPrimaryContactValue(member, type) {
|
||||
const contacts = Array.isArray(member.contacts) ? member.contacts : [];
|
||||
const matching = contacts.filter(contact => contact?.type === type && contact?.value);
|
||||
const primary = matching.find(contact => contact.isPrimary);
|
||||
return (primary || matching[0] || {}).value || '';
|
||||
}
|
||||
|
||||
_trace(trace, type, data = {}) {
|
||||
const entry = {
|
||||
ts: new Date().toISOString(),
|
||||
type,
|
||||
...data
|
||||
};
|
||||
trace.push(entry);
|
||||
if (trace.length > TRACE_LIMIT) {
|
||||
trace.shift();
|
||||
}
|
||||
console.log(`[ClickTT Playwright] ${type}`, JSON.stringify(entry));
|
||||
}
|
||||
|
||||
_attachNetworkLogging(page, trace) {
|
||||
page.on('framenavigated', (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
this._trace(trace, 'navigate', { url: frame.url() });
|
||||
}
|
||||
});
|
||||
page.on('request', (request) => {
|
||||
const resourceType = request.resourceType();
|
||||
const method = request.method();
|
||||
if (!['document', 'xhr', 'fetch'].includes(resourceType) && method === 'GET') {
|
||||
return;
|
||||
}
|
||||
this._trace(trace, 'request', {
|
||||
method,
|
||||
resourceType,
|
||||
url: request.url(),
|
||||
postData: sanitizePayload(request.postData() || '')
|
||||
});
|
||||
});
|
||||
page.on('response', async (response) => {
|
||||
const request = response.request();
|
||||
const resourceType = request.resourceType();
|
||||
const method = request.method();
|
||||
if (!['document', 'xhr', 'fetch'].includes(resourceType) && method === 'GET') {
|
||||
return;
|
||||
}
|
||||
let bodySnippet = '';
|
||||
if (resourceType === 'document') {
|
||||
try {
|
||||
bodySnippet = sanitizePageText(await response.text());
|
||||
} catch (_err) {
|
||||
bodySnippet = '';
|
||||
}
|
||||
}
|
||||
this._trace(trace, 'response', {
|
||||
method,
|
||||
resourceType,
|
||||
url: response.url(),
|
||||
status: response.status(),
|
||||
bodySnippet
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async _openAuthenticatedClickTt(page, { email, password, trace }) {
|
||||
this._trace(trace, 'step', { name: 'open-entry', url: CLICKTT_ENTRY_URL });
|
||||
await page.goto(CLICKTT_ENTRY_URL, { waitUntil: 'domcontentloaded', timeout: 45000 });
|
||||
|
||||
const directLoginOrAuth = page.locator('input[name="email"], input[name="password"]').first();
|
||||
const loginLinkCandidates = [
|
||||
page.getByRole('link', { name: /login|anmelden/i }).first(),
|
||||
page.locator('a[href*="oauth2/authz"], a[href*="oAuthLogin"], a[href*="login"]').first()
|
||||
];
|
||||
if (!(await directLoginOrAuth.count())) {
|
||||
for (const locator of loginLinkCandidates) {
|
||||
if (await locator.count()) {
|
||||
this._trace(trace, 'step', { name: 'click-login-entry' });
|
||||
await locator.click();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const needsLogin = await page.locator('input[name="email"], input[name="password"]').count();
|
||||
if (!needsLogin) {
|
||||
this._trace(trace, 'step', { name: 'session-restored', url: page.url() });
|
||||
await page.waitForURL(/click-tt\.de/, { timeout: 45000 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
throw new HttpError('Die gespeicherte Click-TT-/TTDE-Session ist abgelaufen und es ist kein Passwort gespeichert', 400);
|
||||
}
|
||||
|
||||
this._trace(trace, 'step', { name: 'fill-login-form' });
|
||||
await page.locator('input[name="email"]').first().fill(email);
|
||||
await page.locator('input[name="password"]').first().fill(password);
|
||||
|
||||
const submitLocator = page.locator('button[type="submit"], input[type="submit"]').first();
|
||||
this._trace(trace, 'step', { name: 'submit-login' });
|
||||
await submitLocator.click();
|
||||
await page.waitForURL(/click-tt\.de/, { timeout: 60000 });
|
||||
}
|
||||
|
||||
async _fillSearchForm(page, member) {
|
||||
await this._fillFirstAvailable(page, [
|
||||
'input[name*=".1"]',
|
||||
'input[type="text"]'
|
||||
], member.lastName);
|
||||
await this._fillNthTextInput(page, 1, member.firstName);
|
||||
await this._fillNthTextInput(page, 2, formatGermanDate(member.birthDate));
|
||||
}
|
||||
|
||||
async _fillApplicationForm(page, member, email) {
|
||||
const birthDate = formatGermanDate(member.birthDate);
|
||||
const textInputs = page.locator('input[type="text"]');
|
||||
const inputCount = await textInputs.count();
|
||||
|
||||
const fillIfEmpty = async (index, value) => {
|
||||
if (!value || index >= inputCount) return;
|
||||
const locator = textInputs.nth(index);
|
||||
const currentValue = (await locator.inputValue().catch(() => '')).trim();
|
||||
if (!currentValue) {
|
||||
await locator.fill(value);
|
||||
}
|
||||
};
|
||||
|
||||
await fillIfEmpty(0, member.lastName);
|
||||
await fillIfEmpty(1, member.firstName);
|
||||
await fillIfEmpty(3, birthDate);
|
||||
await fillIfEmpty(5, member.street || '');
|
||||
await fillIfEmpty(6, member.postalCode || '');
|
||||
await fillIfEmpty(7, member.city || '');
|
||||
await fillIfEmpty(13, email);
|
||||
}
|
||||
|
||||
async _abortIfConfirmFlow(page) {
|
||||
const confirmLocator = page.locator('[onclick*="confirm("]').first();
|
||||
if (await confirmLocator.count()) {
|
||||
const text = sanitizePageText(await confirmLocator.innerText().catch(() => '') || await confirmLocator.getAttribute('value').catch(() => '') || '');
|
||||
throw new HttpError(`Der Antrag befindet sich im noch nicht automatisierten click-TT-Confirm-/Neuanlage-Flow${text ? `: ${text}` : ''}`, 409);
|
||||
}
|
||||
}
|
||||
|
||||
async _fillFirstAvailable(page, selectors, value) {
|
||||
for (const selector of selectors) {
|
||||
const locator = page.locator(selector).first();
|
||||
if (await locator.count()) {
|
||||
await locator.fill(value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async _fillNthTextInput(page, index, value) {
|
||||
if (!value) return false;
|
||||
const locator = page.locator('input[type="text"]').nth(index);
|
||||
if (await locator.count()) {
|
||||
await locator.fill(value);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async _clickByText(page, text, trace) {
|
||||
const escaped = escapeRegExp(text);
|
||||
const selectors = [
|
||||
`input[type="submit"][value*="${text}"]`,
|
||||
`input[type="button"][value*="${text}"]`,
|
||||
`button:has-text("${text}")`,
|
||||
`a:has-text("${text}")`,
|
||||
`text=/${escaped}/i`
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const locator = page.locator(selector).first();
|
||||
if (await locator.count()) {
|
||||
this._trace(trace, 'step', {
|
||||
name: 'click',
|
||||
label: text,
|
||||
selector
|
||||
});
|
||||
await locator.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
throw new HttpError(`Click-TT-Element nicht gefunden: ${text}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClickTtPlayerRegistrationService();
|
||||
@@ -255,6 +255,15 @@
|
||||
<span v-if="member.active" @click.stop="quickDeactivateMember(member)" class="action-icon action-icon-deactivate" :title="$t('members.deactivateMember')">
|
||||
⛔
|
||||
</span>
|
||||
<span
|
||||
v-if="member.active"
|
||||
@click.stop="requestClickTtRegistration(member)"
|
||||
class="action-icon"
|
||||
:class="{ 'action-icon-disabled': clickTtPendingMemberIds.includes(member.id) }"
|
||||
:title="clickTtPendingMemberIds.includes(member.id) ? 'Click-TT-Antrag laeuft' : 'Spielberechtigung in click-TT beantragen'"
|
||||
>
|
||||
{{ clickTtPendingMemberIds.includes(member.id) ? '⏳' : '🏓' }}
|
||||
</span>
|
||||
<span @click.stop="openNotesModal(member)" class="action-icon" :title="$t('members.notes')">
|
||||
📝
|
||||
</span>
|
||||
@@ -475,7 +484,8 @@ export default {
|
||||
selectedGroupToAdd: '',
|
||||
showTransferDialog: false,
|
||||
selectedAgeGroup: '',
|
||||
selectedGender: ''
|
||||
selectedGender: '',
|
||||
clickTtPendingMemberIds: []
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
@@ -647,6 +657,37 @@ export default {
|
||||
this.showInfo(this.$t('messages.error'), errorMessage, '', 'error');
|
||||
}
|
||||
},
|
||||
async requestClickTtRegistration(member) {
|
||||
if (this.clickTtPendingMemberIds.includes(member.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this.showConfirm(
|
||||
'Click-TT-Antrag starten',
|
||||
`Soll fuer ${member.firstName} ${member.lastName} der automatisierte Click-TT-Antrag gestartet werden?`,
|
||||
'Aktuell ist nur der Workflow fuer bereits im click-TT-System vorhandene Spieler automatisiert.',
|
||||
'info'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clickTtPendingMemberIds = [...this.clickTtPendingMemberIds, member.id];
|
||||
try {
|
||||
const response = await apiClient.post(`/clubmembers/clicktt-registration/${this.currentClub}/${member.id}`);
|
||||
if (response.data?.success) {
|
||||
await this.showInfo('Click-TT-Antrag', getSafeMessage(response.data.message, 'Der Click-TT-Antrag wurde erfolgreich eingereicht.'), response.data.finalUrl || '', 'success');
|
||||
} else {
|
||||
await this.showInfo('Click-TT-Antrag', getSafeMessage(response.data?.error, 'Der Click-TT-Antrag konnte nicht eingereicht werden.'), '', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Click-TT-Antrag fehlgeschlagen', error);
|
||||
const errorMessage = getSafeErrorMessage(error, 'Der Click-TT-Antrag konnte nicht eingereicht werden.');
|
||||
await this.showInfo('Click-TT-Antrag', errorMessage, '', 'error');
|
||||
} finally {
|
||||
this.clickTtPendingMemberIds = this.clickTtPendingMemberIds.filter(id => id !== member.id);
|
||||
}
|
||||
},
|
||||
toggleNewMember() {
|
||||
this.memberFormIsOpen = !this.memberFormIsOpen;
|
||||
},
|
||||
@@ -1959,6 +2000,17 @@ table td {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.action-icon-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.action-icon-disabled:hover {
|
||||
transform: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.action-icon-deactivate {
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user