Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,29 @@ localazy: {
},
```

## 🔐 Access control (RBAC)

The plugin registers four Strapi permission actions, visible under
**Settings → Administration Panel → Roles → Plugins → Localazy** and
**Settings → Administration Panel → Roles → Settings → Localazy**:

| Action | Unlocks |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Localazy → Read` (`plugin::localazy.read`) | Localazy menu link, Overview, Activity Logs (list + detail), Content-Manager side panel & Localazy status column, read endpoints (identity, project, models, plugin settings, sync cursor, activity logs, troubleshooting bundle, entry-exclusion state). |
| `Localazy → Transfer` (`plugin::localazy.transfer`) | Upload, Download, Entry Exclusion mutations (incl. Content-Manager bulk actions), Activity Log session clearing. |
| `Localazy → Settings → Read` (`plugin::localazy.settings.read`) | Localazy Settings pages (Global Settings, Content Transfer Setup) and reading their config. |
| `Localazy → Settings → Update` (`plugin::localazy.settings.update`) | **Connecting / disconnecting the Localazy account**, webhook setup, updating Content Transfer Setup, and updating Global Settings. |

Server is the enforcement perimeter — all admin-typed plugin routes are
gated by `admin::hasPermissions`, so UI gates are convenience only.

### Upgrade note

Strapi grants new actions to the **Super Admin** role automatically. **Other
roles (Editor, Author, custom roles) keep no Localazy access until an admin
re-grants the actions** under _Settings → Administration Panel → Roles →
Plugins → Localazy_. Plan this step when upgrading from `<= 1.4.x`.

## 🛟 Support

If you encounter any issues or have questions, feel free to contact us through whichever channel suits you best:
Expand Down
14 changes: 11 additions & 3 deletions admin/src/components/LocalazyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import { useTranslation } from 'react-i18next';

import type { PanelComponent } from '@strapi/content-manager/strapi-admin';
import { Typography, Toggle } from '@strapi/design-system';
import { useRBAC } from '@strapi/strapi/admin';
import EntryExclusionService from '../modules/entry-exclusion/services/entry-exclusion-service';
import { PERMISSIONS } from '../constants/permissions';

import '../i18n';

const LocalazyPanel: PanelComponent = () => {
const { t } = useTranslation();

const { allowedActions } = useRBAC([...PERMISSIONS.READ, ...PERMISSIONS.TRANSFER]);

const location = useLocation();
const [isExcluded, setIsExcluded] = useState(false);
const [isLoading, setIsLoading] = useState(true);
Expand Down Expand Up @@ -41,7 +45,7 @@ const LocalazyPanel: PanelComponent = () => {
// Load the current exclusion state when we have the entry information
useEffect(() => {
const loadExclusionState = async () => {
if (!contentType || !documentId) {
if (!contentType || !documentId || !allowedActions.canRead) {
setIsLoading(false);
return;
}
Expand All @@ -59,7 +63,11 @@ const LocalazyPanel: PanelComponent = () => {
};

void loadExclusionState();
}, [contentType, documentId]);
}, [contentType, documentId, allowedActions.canRead]);

if (!allowedActions.canRead) {
return null;
}

// Handle toggle change
const handleToggleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -92,7 +100,7 @@ const LocalazyPanel: PanelComponent = () => {
checked={isExcluded}
onLabel='True'
offLabel='False'
disabled={isLoading || !contentType || !documentId}
disabled={isLoading || !contentType || !documentId || !allowedActions.canTransfer}
onChange={handleToggleChange}
/>
</div>
Expand Down
11 changes: 9 additions & 2 deletions admin/src/components/LocalazyStatusColumn.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useTheme } from 'styled-components';
import { useRBAC } from '@strapi/strapi/admin';
import EntryExclusionService from '../modules/entry-exclusion/services/entry-exclusion-service';
import { PERMISSIONS } from '../constants/permissions';
import '../i18n';

// TODO: define props interface
const LocalazyStatusColumn = ({ data, model }: any) => {
const { t } = useTranslation();
const theme = useTheme();
const { allowedActions } = useRBAC(PERMISSIONS.READ);

const [isExcluded, setIsExcluded] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
const checkStatus = async () => {
if (!data?.documentId || !model) {
if (!data?.documentId || !model || !allowedActions.canRead) {
setIsLoading(false);
return;
}
Expand All @@ -31,7 +34,11 @@ const LocalazyStatusColumn = ({ data, model }: any) => {
};

void checkStatus();
}, [data?.documentId, model]);
}, [data?.documentId, model, allowedActions.canRead]);

if (!allowedActions.canRead) {
return null;
}

if (isLoading) {
return <span style={{ color: theme.colors.neutral600, fontSize: '12px' }}>...</span>;
Expand Down
35 changes: 35 additions & 0 deletions admin/src/constants/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { PLUGIN_ID } from '../pluginId';

/**
* Mirror of `server/src/constants/permissions.ts`. Admin code can't import from
* `server/`, so any rename must be applied in both files.
*
* `useRBAC` derives the boolean it returns under `allowedActions` from the LAST
* dotted segment of each UID, lowercased and prefixed with `can`:
*
* plugin::localazy.read → canRead
* plugin::localazy.transfer → canTransfer
* plugin::localazy.settings.read → canRead (collides with READ above!)
* plugin::localazy.settings.update → canUpdate
*
* Because `settings.read` and `read` both collapse to `canRead`, never pass
* both into the same `useRBAC` call — issue separate calls per distinct UID
* you need to check (one call returns one `canX` boolean per unique segment).
*/
export const PERMISSION_UIDS = {
READ: `plugin::${PLUGIN_ID}.read`,
TRANSFER: `plugin::${PLUGIN_ID}.transfer`,
SETTINGS_READ: `plugin::${PLUGIN_ID}.settings.read`,
SETTINGS_UPDATE: `plugin::${PLUGIN_ID}.settings.update`,
} as const;

export type PermissionUid = (typeof PERMISSION_UIDS)[keyof typeof PERMISSION_UIDS];

type PermissionDescriptor = { action: PermissionUid; subject: null };

export const PERMISSIONS: Record<keyof typeof PERMISSION_UIDS, PermissionDescriptor[]> = {
READ: [{ action: PERMISSION_UIDS.READ, subject: null }],
TRANSFER: [{ action: PERMISSION_UIDS.TRANSFER, subject: null }],
SETTINGS_READ: [{ action: PERMISSION_UIDS.SETTINGS_READ, subject: null }],
SETTINGS_UPDATE: [{ action: PERMISSION_UIDS.SETTINGS_UPDATE, subject: null }],
};
18 changes: 15 additions & 3 deletions admin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { PLUGIN_ID } from './pluginId';
import { Initializer } from './components/Initializer';
import { Localazy } from './modules/@common/components/Icons/Localazy';
import { PERMISSIONS } from './constants/permissions';
import React from 'react';

export default {
Expand Down Expand Up @@ -81,14 +82,19 @@ export default {
});

if (contentManagerPlugin?.apis?.addBulkAction) {
const { useNotification } = await import('@strapi/strapi/admin');
const { useNotification, useRBAC } = await import('@strapi/strapi/admin');
await import('./i18n');
const { useTranslation } = await import('react-i18next');

const ExcludeFromTranslationAction = (props: any) => {
const { documents, model } = props;
const { toggleNotification } = useNotification();
const { t } = useTranslation();
const { allowedActions } = useRBAC(PERMISSIONS.TRANSFER);

if (!allowedActions.canTransfer) {
return null;
}

return {
label: t('plugin_settings.bulk_action_exclude_from_translation'),
Expand Down Expand Up @@ -133,6 +139,11 @@ export default {
const IncludeToTranslationAction = ({ documents, model }: any) => {
const { toggleNotification } = useNotification();
const { t } = useTranslation();
const { allowedActions } = useRBAC(PERMISSIONS.TRANSFER);

if (!allowedActions.canTransfer) {
return null;
}

return {
label: t('plugin_settings.bulk_action_include_to_translation'),
Expand Down Expand Up @@ -194,6 +205,7 @@ const addMenuLink = (app: any) => {
defaultMessage: 'Localazy',
},
Component: () => import('./pages/LocalazyApp'),
permissions: PERMISSIONS.READ,
});
};

Expand All @@ -216,7 +228,7 @@ const addSettingsSection = (app: any) => {
},
to: `${PLUGIN_ID}/global-settings`,
Component: () => import('./pages/LocalazyGlobalSettings'),
permissions: [],
permissions: PERMISSIONS.SETTINGS_READ,
},
// Content Transfer Setup
{
Expand All @@ -227,7 +239,7 @@ const addSettingsSection = (app: any) => {
},
to: `${PLUGIN_ID}/content-transfer-setup`,
Component: () => import('./pages/LocalazyContentTransferSetup'),
permissions: [],
permissions: PERMISSIONS.SETTINGS_READ,
},
]
);
Expand Down
24 changes: 20 additions & 4 deletions admin/src/modules/login/components/LoginButton.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import { Button } from '@strapi/design-system';
import { Button, Tooltip } from '@strapi/design-system';
import { useTranslation } from 'react-i18next';
import { useRBAC } from '@strapi/strapi/admin';
import { getOAuthAuthorizationUrl } from '@localazy/generic-connector-client';
import LocalazyLoginService from '../services/localazy-login-service';
import { getStrapiDefaultLocale } from '../../@common/utils/get-default-locale';
import { isoStrapiToLocalazy } from '../../@common/utils/iso-locales-utils';
import config from '../../../config';
import { LocalazyIdentity } from '../../user/model/localazy-identity';
import { PERMISSIONS } from '../../../constants/permissions';
import { Locales } from '@localazy/api-client';

interface LoginButtonProps {
Expand All @@ -16,6 +18,10 @@ interface LoginButtonProps {

const LoginButton = (props: LoginButtonProps) => {
const { t } = useTranslation();
// useRBAC derives action names from the last dotted segment of the UID,
// so `plugin::localazy.settings.update` resolves to `canUpdate`.
const { allowedActions } = useRBAC(PERMISSIONS.SETTINGS_UPDATE);
const canConnect = !!allowedActions.canUpdate;

const [isLoading, setIsLoading] = useState(false);
const login = async () => {
Expand All @@ -42,11 +48,21 @@ const LoginButton = (props: LoginButtonProps) => {
props.onResultFetched(pollResult);
};

const button = (
<Button variant='default' size='L' loading={isLoading} onClick={login} disabled={!canConnect}>
{t('login.login_with_localazy')}
</Button>
);

return (
<div>
<Button variant='default' size='L' loading={isLoading} onClick={login}>
{t('login.login_with_localazy')}
</Button>
{canConnect ? (
button
) : (
<Tooltip label={t('login.requires_settings_update_permission')}>
<span>{button}</span>
</Tooltip>
)}
</div>
);
};
Expand Down
24 changes: 20 additions & 4 deletions admin/src/modules/login/components/LogoutButton.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useState } from 'react';
import { Button } from '@strapi/design-system';
import { Button, Tooltip } from '@strapi/design-system';
import { useTranslation } from 'react-i18next';
import { SignOut } from '@strapi/icons';
import { useRBAC } from '@strapi/strapi/admin';
import LocalazyUserService from '../../user/services/localazy-user-service';
import { useLocalazyIdentity } from '../../../state/localazy-identity';
import { emptyIdentity } from '../../user/model/localazy-identity';
import { PERMISSIONS } from '../../../constants/permissions';

interface LogoutButtonProps {
onResultFetched: () => void;
Expand All @@ -14,6 +16,10 @@ const LogoutButton: React.FC<LogoutButtonProps> = (props) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const { setIdentity } = useLocalazyIdentity();
// useRBAC derives action names from the last dotted segment of the UID,
// so `plugin::localazy.settings.update` resolves to `canUpdate`.
const { allowedActions } = useRBAC(PERMISSIONS.SETTINGS_UPDATE);
const canDisconnect = !!allowedActions.canUpdate;

const logout = async () => {
setIsLoading(true);
Expand All @@ -28,11 +34,21 @@ const LogoutButton: React.FC<LogoutButtonProps> = (props) => {
props.onResultFetched();
};

const button = (
<Button startIcon={<SignOut />} variant='secondary' loading={isLoading} onClick={logout} disabled={!canDisconnect}>
{t('login.logout_from_localazy')}
</Button>
);

return (
<div>
<Button startIcon={<SignOut />} variant='secondary' loading={isLoading} onClick={logout}>
{t('login.logout_from_localazy')}
</Button>
{canDisconnect ? (
button
) : (
<Tooltip label={t('login.requires_settings_update_permission')}>
<span>{button}</span>
</Tooltip>
)}
</div>
);
};
Expand Down
2 changes: 2 additions & 0 deletions admin/src/modules/login/locale/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ export default {
'You have to own a Localazy account for the plugin to work properly.',
read_the_documentation: 'Read the documentation',
logout_from_localazy: 'Disconnect from Localazy',
requires_settings_update_permission:
'Requires Localazy "settings.update" permission. Ask an admin to grant it under Settings → Roles → Plugins → Localazy.',
};
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ export default class PluginSettingsService {
}
}

// Read-gated counterpart for per-user UI prefs (last visited route, sort
// prefs). Server filters the body to a fixed allowlist; sending anything else
// is silently dropped, so callers must only pass UI-pref fields here.
static async updatePluginSettingsUiPrefs(data: { defaultRoute?: string; activityLogsSort?: any }) {
try {
const result = await axiosInstance.put(`${BASE_PATH}/ui-prefs`, data);

return result.data;
} catch (e) {
throw e;
}
}

static async getSyncCursor() {
try {
const result = await axiosInstance.get(`${BASE_PATH}/sync-cursor`);
Expand Down
4 changes: 2 additions & 2 deletions admin/src/pages/ActivityLogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const ActivityLogs: React.FC<ActivityLogsProps> = (props) => {
const debouncedSaveSortPrefs = useCallback((prefs: Record<string, { key: string; direction: string }>) => {
if (saveSortTimerRef.current) clearTimeout(saveSortTimerRef.current);
saveSortTimerRef.current = setTimeout(() => {
void PluginSettingsService.updatePluginSettings({ activityLogsSort: prefs });
void PluginSettingsService.updatePluginSettingsUiPrefs({ activityLogsSort: prefs });
}, 1000);
}, []);

Expand Down Expand Up @@ -102,7 +102,7 @@ const ActivityLogs: React.FC<ActivityLogsProps> = (props) => {
void fetchSessions('upload');
};
void init();
void PluginSettingsService.updatePluginSettings({ defaultRoute: PLUGIN_ROUTES.ACTIVITY_LOGS });
void PluginSettingsService.updatePluginSettingsUiPrefs({ defaultRoute: PLUGIN_ROUTES.ACTIVITY_LOGS });
}, []);

return (
Expand Down
Loading
Loading