Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,36 @@ localazy: {
},
```

## 🔐 Access control (RBAC)

The plugin registers four Strapi permission actions, all visible under
**Settings → Administration Panel → Roles → Plugins → Localazy** (grouped
by `General` and `Settings` sub-categories):

| 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, **content-transfer-setup**, 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. Reading the content-transfer-setup is bundled under `Read` above so transfer-only roles can still open the Upload/Download pages. |
| `Localazy → Settings → Read` (`plugin::localazy.settings.read`) | Adds the Localazy Settings menu links (Global Settings, Content Transfer Setup) to the admin sidebar. Form controls render read-only — Save, Cancel, the Content Transfer Setup tree, and the webhook setup buttons are disabled without `Settings → Update`. |
| `Localazy → Settings → Update` (`plugin::localazy.settings.update`) | **Connecting / disconnecting the Localazy account**, webhook setup, updating Content Transfer Setup, and updating Global Settings. |

> **Note:** the **Webhook author** picker in _Localazy Settings → Global
> Settings_ populates from Strapi's core `/admin/users` endpoint, which is
> gated by Strapi's own `admin::users.read` permission — independent of the
> plugin's actions. A role with only the plugin's `Settings → Read` will
> still see the rest of the page; the author dropdown silently falls back
> to an empty list.

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
2 changes: 2 additions & 0 deletions admin/src/modules/@common/components/LanguagesSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface LanguageSelectorProps {
projectLanguages: any[];
preselectedLanguages: any[];
onChange: (values: any) => void;
disabled?: boolean;
}

const LanguagesSelector: React.FC<LanguageSelectorProps> = (props: LanguageSelectorProps) => {
Expand Down Expand Up @@ -46,6 +47,7 @@ const LanguagesSelector: React.FC<LanguageSelectorProps> = (props: LanguageSelec
onClear={() => setSelectedLanguages([])}
value={selectedLanguages || []}
onChange={(values: any) => onChange(values)}
disabled={props.disabled}
multi
withTags
>
Expand Down
22 changes: 18 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,8 @@ interface LoginButtonProps {

const LoginButton = (props: LoginButtonProps) => {
const { t } = useTranslation();
const { allowedActions } = useRBAC(PERMISSIONS.SETTINGS_UPDATE);
const canConnect = !!allowedActions.canUpdate;

const [isLoading, setIsLoading] = useState(false);
const login = async () => {
Expand All @@ -42,11 +46,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
22 changes: 18 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,8 @@ const LogoutButton: React.FC<LogoutButtonProps> = (props) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const { setIdentity } = useLocalazyIdentity();
const { allowedActions } = useRBAC(PERMISSIONS.SETTINGS_UPDATE);
const canDisconnect = !!allowedActions.canUpdate;

const logout = async () => {
setIsLoading(true);
Expand All @@ -28,11 +32,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.',
};
Loading
Loading