Add a Permissions menu with privilege keys: list, create, remove and use
Permissions > Use Privilege Key redeems a key (tokenuse). Permissions > Privilege Keys opens a per-tab window listing the server's keys with their group, channel, creation date and description; keys can be created, removed, copied and reloaded. The create dialog takes a server group, or a channel group in a chosen channel, plus a description, offers only groups we have the power to add members to, and shows each new key for copying. tokenlist and tokenadd answer with notifytokenlist / notifytokenadd ahead of the command's completion, so core collects them the way it does the ban list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -662,6 +662,8 @@ final class ConnectionEventHandler implements TS3Listener {
|
||||
if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e);
|
||||
else if ("notifychannelpermlist".equals(e.getCommand())) conn.channels.onPermListEntry(e);
|
||||
else if ("notifybanlist".equals(e.getCommand())) conn.bans.onListEntry(e);
|
||||
else if ("notifytokenlist".equals(e.getCommand())) conn.privilegeKeys.onListEntry(e);
|
||||
else if ("notifytokenadd".equals(e.getCommand())) conn.privilegeKeys.onAdded(e);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A privilege key (token): a one-time code that puts whoever redeems it into a server
|
||||
* group, or into a channel group in one channel.
|
||||
*
|
||||
* @param groupId the server or channel group it grants
|
||||
* @param channelId the channel a channel group key applies in; 0 for a server group key
|
||||
*/
|
||||
public record PrivilegeKey(String token, boolean channelGroup, int groupId, int channelId,
|
||||
long createdEpochSeconds, String description) {
|
||||
|
||||
/** Reads a key off a {@code notifytokenlist} event's fields. */
|
||||
static PrivilegeKey from(Map<String, String> fields) {
|
||||
return new PrivilegeKey(orEmpty(fields.get("token")),
|
||||
parseLong(fields.get("token_type")) == 1,
|
||||
(int) parseLong(fields.get("token_id1")),
|
||||
(int) parseLong(fields.get("token_id2")),
|
||||
parseLong(fields.get("token_created")),
|
||||
orEmpty(fields.get("token_description")));
|
||||
}
|
||||
|
||||
private static String orEmpty(String s) {
|
||||
return s == null || s.equals("null") ? "" : s;
|
||||
}
|
||||
|
||||
private static long parseLong(String s) {
|
||||
try {
|
||||
return s == null ? 0 : Long.parseLong(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import com.github.manevolent.ts3j.command.CommandException;
|
||||
import com.github.manevolent.ts3j.command.SingleCommand;
|
||||
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
|
||||
import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent;
|
||||
import com.github.manevolent.ts3j.protocol.ProtocolRole;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The server's privilege keys: listing, creating, deleting and redeeming them.
|
||||
*
|
||||
* <p>Split out of {@link TeamspeakConnection} like {@link BanAdmin}, and answered the same
|
||||
* way: {@code tokenlist} and {@code tokenadd} reply with notifications ahead of the
|
||||
* command's own completion. Every call here blocks and must run off the UI thread; the
|
||||
* connection's public wrappers do that.
|
||||
*/
|
||||
final class PrivilegeKeyAdmin {
|
||||
|
||||
/** What {@code tokenlist} answers when there are no keys at all. */
|
||||
private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501;
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
|
||||
/** The {@code tokenlist} reply in progress; one request may be in flight at a time. */
|
||||
private volatile List<PrivilegeKey> listing;
|
||||
/** The key the {@code tokenadd} in flight was answered with. */
|
||||
private volatile String created;
|
||||
|
||||
PrivilegeKeyAdmin(TeamspeakConnection conn) {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
List<PrivilegeKey> list() throws Exception {
|
||||
List<PrivilegeKey> keys = Collections.synchronizedList(new ArrayList<>());
|
||||
listing = keys;
|
||||
try {
|
||||
conn.socket().executeCommand(new SingleCommand("tokenlist", ProtocolRole.CLIENT)).complete();
|
||||
conn.awaitEventsProcessed();
|
||||
} catch (CommandException e) {
|
||||
if (e.getErrorId() != ERROR_DATABASE_EMPTY_RESULT) throw e;
|
||||
} finally {
|
||||
listing = null;
|
||||
}
|
||||
return new ArrayList<>(keys);
|
||||
}
|
||||
|
||||
void onListEntry(UnknownTeamspeakEvent e) {
|
||||
List<PrivilegeKey> keys = listing;
|
||||
if (keys != null) keys.add(PrivilegeKey.from(e.getMap()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a key.
|
||||
*
|
||||
* @param channelId the channel a channel group key applies in; ignored for a server group
|
||||
* @return the new key
|
||||
*/
|
||||
synchronized String add(boolean channelGroup, int groupId, int channelId, String description) throws Exception {
|
||||
SingleCommand cmd = new SingleCommand("tokenadd", ProtocolRole.CLIENT);
|
||||
cmd.add(new CommandSingleParameter("tokentype", channelGroup ? "1" : "0"));
|
||||
cmd.add(new CommandSingleParameter("tokenid1", Integer.toString(groupId)));
|
||||
cmd.add(new CommandSingleParameter("tokenid2", channelGroup ? Integer.toString(channelId) : "0"));
|
||||
cmd.add(new CommandSingleParameter("tokendescription", description));
|
||||
created = null;
|
||||
conn.socket().executeCommand(cmd).complete();
|
||||
conn.awaitEventsProcessed();
|
||||
String token = created;
|
||||
if (token == null || token.isEmpty()) throw new IllegalStateException("The server did not return the key");
|
||||
return token;
|
||||
}
|
||||
|
||||
void onAdded(UnknownTeamspeakEvent e) {
|
||||
created = e.get("token");
|
||||
}
|
||||
|
||||
void delete(String token) throws Exception {
|
||||
conn.socket().executeCommand(new SingleCommand("tokendelete", ProtocolRole.CLIENT,
|
||||
new CommandSingleParameter("token", token))).complete();
|
||||
}
|
||||
|
||||
/** Redeems a key; the server announces the resulting group change itself. */
|
||||
void use(String token) throws Exception {
|
||||
conn.socket().executeCommand(new SingleCommand("tokenuse", ProtocolRole.CLIENT,
|
||||
new CommandSingleParameter("token", token))).complete();
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,15 @@ public final class ServerModel {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The regular server or channel groups we have the power to put someone in, so may make a privilege key for. */
|
||||
public synchronized List<Group> privilegeKeyGroups(boolean channelGroups) {
|
||||
List<Group> out = new ArrayList<>();
|
||||
for (Group g : channelGroups ? allChannelGroups() : allServerGroups()) {
|
||||
if (canToggleGroup(g, false)) out.add(g);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static boolean hasPower(int needed, int own) {
|
||||
if (own == -1) return true;
|
||||
if (needed == -1) return false;
|
||||
@@ -397,6 +406,20 @@ public final class ServerModel {
|
||||
return children;
|
||||
}
|
||||
|
||||
/** Every channel, depth first in the order the tree shows them. */
|
||||
public synchronized List<ChannelNode> channelsInTreeOrder() {
|
||||
List<ChannelNode> out = new ArrayList<>();
|
||||
addInTreeOrder(0, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
private void addInTreeOrder(int parentId, List<ChannelNode> out) {
|
||||
for (ChannelNode c : childrenOf(parentId)) {
|
||||
out.add(c);
|
||||
addInTreeOrder(c.id, out);
|
||||
}
|
||||
}
|
||||
|
||||
/** The channel of that exact name directly below {@code parentId}, or {@code null}. */
|
||||
public synchronized ChannelNode findChannelByName(int parentId, String name) {
|
||||
for (ChannelNode c : channels.values()) {
|
||||
|
||||
@@ -95,8 +95,9 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
final ConnectionStatsCollector stats = new ConnectionStatsCollector(this);
|
||||
/** Channel editing and the server's icon store; package-private for {@link ConnectionEventHandler}. */
|
||||
final ChannelAdmin channels = new ChannelAdmin(this);
|
||||
/** The server's ban list; also package-private for {@link ConnectionEventHandler}. */
|
||||
/** The server's ban list and privilege keys; also package-private for {@link ConnectionEventHandler}. */
|
||||
final BanAdmin bans = new BanAdmin(this);
|
||||
final PrivilegeKeyAdmin privilegeKeys = new PrivilegeKeyAdmin(this);
|
||||
private final AvatarAdmin avatarAdmin = new AvatarAdmin(this);
|
||||
|
||||
/*
|
||||
@@ -954,6 +955,43 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- privilege keys ----
|
||||
|
||||
/** Reads the server's privilege keys, delivering them (or a failure message) off the UI thread. */
|
||||
public void requestPrivilegeKeys(BiConsumer<List<PrivilegeKey>, String> callback) {
|
||||
run(callback, privilegeKeys::list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a privilege key for a server group, or for a channel group in {@code channelId}.
|
||||
*
|
||||
* @param callback given the new key, or the failure message
|
||||
*/
|
||||
public void addPrivilegeKey(boolean channelGroup, int groupId, int channelId, String description,
|
||||
BiConsumer<String, String> callback) {
|
||||
run(callback, () -> privilegeKeys.add(channelGroup, groupId, channelId, description));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes privilege keys one by one, stopping at the first the server refuses.
|
||||
*
|
||||
* @param callback given {@code null} on success, or the failure message
|
||||
*/
|
||||
public void deletePrivilegeKeys(Collection<String> tokens, Consumer<String> callback) {
|
||||
run((ignored, error) -> callback.accept(error), () -> {
|
||||
for (String token : tokens) privilegeKeys.delete(token);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param callback given {@code null} on success, or the failure message */
|
||||
public void usePrivilegeKey(String token, Consumer<String> callback) {
|
||||
run((ignored, error) -> callback.accept(error), () -> {
|
||||
privilegeKeys.use(token);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- bans ----
|
||||
|
||||
/** Reads the server's ban list, delivering it (or a failure message) off the UI thread. */
|
||||
|
||||
@@ -387,6 +387,11 @@ public final class ServerSession {
|
||||
return permits("b_client_ban_list");
|
||||
}
|
||||
|
||||
/** Whether the server grants us {@code b_virtualserver_token_list}; offered while unknown. */
|
||||
public boolean canListPrivilegeKeys() {
|
||||
return permits("b_virtualserver_token_list");
|
||||
}
|
||||
|
||||
/** Whether the server lets us upload an avatar ({@code i_client_max_avatar_filesize}); offered while unknown. */
|
||||
public boolean canSetAvatar() {
|
||||
return permits("i_client_max_avatar_filesize");
|
||||
|
||||
@@ -283,6 +283,21 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
if (selected != null) selected.showBanList(banReasonPresets());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canListPrivilegeKeys() {
|
||||
return selected != null && selected.session().canListPrivilegeKeys();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowPrivilegeKeys() {
|
||||
if (selected != null) selected.showPrivilegeKeys();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUsePrivilegeKey() {
|
||||
if (selected != null) selected.usePrivilegeKey();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowSettings() {
|
||||
showSettings();
|
||||
|
||||
@@ -18,7 +18,7 @@ import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
|
||||
/**
|
||||
* The main window's menu bar: Connections, Bookmarks, Self, Tools and Help.
|
||||
* The main window's menu bar: Connections, Bookmarks, Self, Permissions, Tools and Help.
|
||||
* Talks to {@link MainFrame} only through {@link Listener}; the away/commander
|
||||
* item states are pushed in by {@link #refresh}, mirroring how {@link MainToolbar}
|
||||
* is kept in sync.
|
||||
@@ -66,6 +66,13 @@ final class MainMenuBar extends JMenuBar {
|
||||
|
||||
void onShowBanList();
|
||||
|
||||
/** Whether the current server lets us list its privilege keys; asked as the Permissions menu opens. */
|
||||
boolean canListPrivilegeKeys();
|
||||
|
||||
void onShowPrivilegeKeys();
|
||||
|
||||
void onUsePrivilegeKey();
|
||||
|
||||
void onShowSettings();
|
||||
|
||||
void onShowAbout();
|
||||
@@ -88,6 +95,8 @@ final class MainMenuBar extends JMenuBar {
|
||||
private JMenuItem avatarItem;
|
||||
private JMenuItem deleteAvatarItem;
|
||||
private JMenuItem banListItem;
|
||||
private JMenuItem privilegeKeysItem;
|
||||
private JMenuItem usePrivilegeKeyItem;
|
||||
|
||||
MainMenuBar(Bookmarks bookmarks, Listener listener) {
|
||||
this.bookmarks = bookmarks;
|
||||
@@ -171,6 +180,28 @@ final class MainMenuBar extends JMenuBar {
|
||||
}
|
||||
});
|
||||
|
||||
JMenu permissions = new JMenu("Permissions");
|
||||
privilegeKeysItem = new JMenuItem("Privilege Keys", Icons.of("TOKEN"));
|
||||
privilegeKeysItem.addActionListener(e -> listener.onShowPrivilegeKeys());
|
||||
usePrivilegeKeyItem = new JMenuItem("Use Privilege Key", Icons.of("TOKEN_USE"));
|
||||
usePrivilegeKeyItem.addActionListener(e -> listener.onUsePrivilegeKey());
|
||||
permissions.add(privilegeKeysItem);
|
||||
permissions.add(usePrivilegeKeyItem);
|
||||
permissions.addMenuListener(new MenuListener() {
|
||||
@Override
|
||||
public void menuSelected(MenuEvent e) {
|
||||
privilegeKeysItem.setVisible(!privilegeKeysItem.isEnabled() || listener.canListPrivilegeKeys());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void menuDeselected(MenuEvent e) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void menuCanceled(MenuEvent e) {
|
||||
}
|
||||
});
|
||||
|
||||
JMenu tools = new JMenu("Tools");
|
||||
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
|
||||
identitiesItem.addActionListener(e -> listener.onShowIdentities());
|
||||
@@ -214,6 +245,7 @@ final class MainMenuBar extends JMenuBar {
|
||||
add(connections);
|
||||
add(bookmarksMenu);
|
||||
add(self);
|
||||
add(permissions);
|
||||
add(tools);
|
||||
add(help);
|
||||
}
|
||||
@@ -226,6 +258,8 @@ final class MainMenuBar extends JMenuBar {
|
||||
avatarItem.setEnabled(connected);
|
||||
deleteAvatarItem.setEnabled(connected);
|
||||
banListItem.setEnabled(connected);
|
||||
privilegeKeysItem.setEnabled(connected);
|
||||
usePrivilegeKeyItem.setEnabled(connected);
|
||||
awayItem.setSelected(away);
|
||||
commanderItem.setSelected(commander);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.Group;
|
||||
import com.ts3client.net.ServerModel;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
|
||||
import javax.swing.Box;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JRadioButton;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.FlowLayout;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.Toolkit;
|
||||
import java.awt.Window;
|
||||
import java.awt.datatransfer.StringSelection;
|
||||
|
||||
/**
|
||||
* Creates privilege keys, as the official client's "Create Privilege Key" dialog: a server
|
||||
* group, or a channel group in a chosen channel, and an optional description. Each key
|
||||
* created is shown for copying, and the dialog stays open for the next one.
|
||||
*/
|
||||
final class PrivilegeKeyCreateDialog extends JDialog {
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
private final Runnable onCreated;
|
||||
|
||||
private final JRadioButton serverGroup = new JRadioButton("Server Group", true);
|
||||
private final JRadioButton channelGroup = new JRadioButton("Channel Group");
|
||||
private final JComboBox<Group> groupBox = new JComboBox<>();
|
||||
private final JComboBox<ChannelNode> channelBox = new JComboBox<>();
|
||||
private final JTextField descriptionField = new JTextField(28);
|
||||
private final JTextField keyField = new JTextField(28);
|
||||
private final JButton copy = new JButton("Copy", Icons.of("COPY"));
|
||||
private final JButton create = new JButton("Create");
|
||||
|
||||
/** @param onCreated runs on the UI thread after each key is created */
|
||||
PrivilegeKeyCreateDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, Runnable onCreated) {
|
||||
super(owner, "Create Privilege Key", ModalityType.APPLICATION_MODAL);
|
||||
this.conn = conn;
|
||||
this.onCreated = onCreated;
|
||||
|
||||
ButtonGroup type = new ButtonGroup();
|
||||
type.add(serverGroup);
|
||||
type.add(channelGroup);
|
||||
serverGroup.addActionListener(e -> typeChanged());
|
||||
channelGroup.addActionListener(e -> typeChanged());
|
||||
JPanel typeRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
|
||||
typeRow.add(serverGroup);
|
||||
typeRow.add(Box.createHorizontalStrut(UIScale.scale(12)));
|
||||
typeRow.add(channelGroup);
|
||||
|
||||
groupBox.setRenderer(new DefaultListCellRenderer() {
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
|
||||
boolean selected, boolean focused) {
|
||||
super.getListCellRendererComponent(list, value, index, selected, focused);
|
||||
if (value instanceof Group g) {
|
||||
setText(g.name);
|
||||
setIcon(groupIcons.iconOf(g));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
});
|
||||
groupBox.addActionListener(e -> updateCreate());
|
||||
ServerModel model = conn.getModel();
|
||||
channelBox.setRenderer(new DefaultListCellRenderer() {
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
|
||||
boolean selected, boolean focused) {
|
||||
super.getListCellRendererComponent(list, value, index, selected, focused);
|
||||
if (value instanceof ChannelNode c) setText(model.channelPath(c.id));
|
||||
return this;
|
||||
}
|
||||
});
|
||||
for (ChannelNode c : model.channelsInTreeOrder()) channelBox.addItem(c);
|
||||
channelBox.setToolTipText("When creating a privilege key for a channel group, specify the channel here");
|
||||
descriptionField.setToolTipText("Optional description for this privilege key");
|
||||
descriptionField.addActionListener(e -> create());
|
||||
|
||||
keyField.setEditable(false);
|
||||
copy.setEnabled(false);
|
||||
copy.addActionListener(e -> Toolkit.getDefaultToolkit().getSystemClipboard()
|
||||
.setContents(new StringSelection(keyField.getText()), null));
|
||||
JPanel keyRow = new JPanel(new BorderLayout(4, 0));
|
||||
keyRow.add(keyField, BorderLayout.CENTER);
|
||||
keyRow.add(copy, BorderLayout.EAST);
|
||||
|
||||
FormPanel form = new FormPanel();
|
||||
GridBagConstraints c = FormPanel.gbc();
|
||||
FormPanel.addRow(form, c, 0, new JLabel("Group Type:"), typeRow);
|
||||
FormPanel.addRow(form, c, 1, new JLabel("Group:"), groupBox);
|
||||
FormPanel.addRow(form, c, 2, new JLabel("Channel:"), channelBox);
|
||||
FormPanel.addRow(form, c, 3, new JLabel("Description:"), descriptionField);
|
||||
FormPanel.addRow(form, c, 4, new JLabel("Privilege Key:"), keyRow);
|
||||
|
||||
create.addActionListener(e -> create());
|
||||
JButton close = new JButton("Close");
|
||||
close.addActionListener(e -> dispose());
|
||||
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT));
|
||||
buttons.setBorder(new ScaledEmptyBorder(0, 8, 4, 8));
|
||||
buttons.add(create);
|
||||
buttons.add(close);
|
||||
getRootPane().setDefaultButton(create);
|
||||
|
||||
getContentPane().setLayout(new BorderLayout());
|
||||
getContentPane().add(form, BorderLayout.CENTER);
|
||||
getContentPane().add(buttons, BorderLayout.SOUTH);
|
||||
|
||||
typeChanged();
|
||||
Dialogs.closeOnEscape(this);
|
||||
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||
pack();
|
||||
setResizable(false);
|
||||
setLocationRelativeTo(owner);
|
||||
}
|
||||
|
||||
private boolean forChannelGroup() {
|
||||
return channelGroup.isSelected();
|
||||
}
|
||||
|
||||
private void typeChanged() {
|
||||
groupBox.removeAllItems();
|
||||
for (Group g : conn.getModel().privilegeKeyGroups(forChannelGroup())) groupBox.addItem(g);
|
||||
channelBox.setEnabled(forChannelGroup());
|
||||
updateCreate();
|
||||
}
|
||||
|
||||
private void updateCreate() {
|
||||
create.setEnabled(groupBox.getSelectedItem() != null
|
||||
&& (!forChannelGroup() || channelBox.getSelectedItem() != null));
|
||||
}
|
||||
|
||||
private void create() {
|
||||
if (!create.isEnabled()) return;
|
||||
Group group = (Group) groupBox.getSelectedItem();
|
||||
ChannelNode channel = (ChannelNode) channelBox.getSelectedItem();
|
||||
create.setEnabled(false);
|
||||
conn.addPrivilegeKey(forChannelGroup(), group.id, channel == null ? 0 : channel.id,
|
||||
descriptionField.getText().trim(), (key, error) -> SwingUtilities.invokeLater(() -> {
|
||||
if (!isDisplayable()) return;
|
||||
updateCreate();
|
||||
if (error != null) {
|
||||
JOptionPane.showMessageDialog(this, "Failed to add Privilege Key: " + error,
|
||||
getTitle(), JOptionPane.ERROR_MESSAGE);
|
||||
return;
|
||||
}
|
||||
keyField.setText(key);
|
||||
keyField.selectAll();
|
||||
keyField.requestFocusInWindow();
|
||||
copy.setEnabled(true);
|
||||
onCreated.run();
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.net.Group;
|
||||
import com.ts3client.net.PrivilegeKey;
|
||||
import com.ts3client.net.ServerModel;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
|
||||
import javax.swing.AbstractAction;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JPopupMenu;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTable;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.ListSelectionModel;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.table.AbstractTableModel;
|
||||
import javax.swing.table.DefaultTableCellRenderer;
|
||||
import javax.swing.table.TableRowSorter;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Frame;
|
||||
import java.awt.Toolkit;
|
||||
import java.awt.datatransfer.StringSelection;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* The server's privilege keys, as the official client's Permissions > Privilege Keys
|
||||
* window: Create and Remove above the list, Reload below it.
|
||||
*
|
||||
* <p>One per server tab and modeless, like the ban list; the tab closes it when the
|
||||
* connection goes away.
|
||||
*/
|
||||
final class PrivilegeKeysDialog extends JDialog {
|
||||
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private enum Column {
|
||||
KEY("Privilege Key", 320),
|
||||
TYPE("Type", 110),
|
||||
GROUP("Group", 140),
|
||||
CHANNEL("Channel", 140),
|
||||
CREATED("Created", 150),
|
||||
DESCRIPTION("Description", 180);
|
||||
|
||||
final String title;
|
||||
final int width;
|
||||
|
||||
Column(String title, int width) {
|
||||
this.title = title;
|
||||
this.width = width;
|
||||
}
|
||||
}
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
private final GroupIcons groupIcons;
|
||||
|
||||
private final KeyTableModel tableModel = new KeyTableModel();
|
||||
private final JTable table = new JTable(tableModel);
|
||||
private final JButton createButton = new JButton("Create", Icons.of("ADD"));
|
||||
private final JButton removeButton = new JButton("Remove", Icons.of("DELETE"));
|
||||
private final JLabel status = new JLabel(" ");
|
||||
|
||||
private boolean loading;
|
||||
|
||||
PrivilegeKeysDialog(Frame owner, TeamspeakConnection conn, GroupIcons groupIcons) {
|
||||
super(owner, "Privilege Keys", false);
|
||||
this.conn = conn;
|
||||
this.groupIcons = groupIcons;
|
||||
|
||||
JPanel content = new JPanel(new BorderLayout(0, UIScale.scale(6)));
|
||||
content.setBackground(Theme.windowBg());
|
||||
content.setBorder(new ScaledEmptyBorder(10, 12, 10, 12));
|
||||
content.add(buildToolbar(), BorderLayout.NORTH);
|
||||
content.add(buildTable(), BorderLayout.CENTER);
|
||||
content.add(buildBottom(), BorderLayout.SOUTH);
|
||||
setContentPane(content);
|
||||
bindKeys();
|
||||
|
||||
serverNameChanged();
|
||||
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||
setPreferredSize(UIScale.scale(new Dimension(1040, 400)));
|
||||
pack();
|
||||
setLocationRelativeTo(owner);
|
||||
reload();
|
||||
}
|
||||
|
||||
// ---- construction ----
|
||||
|
||||
private JPanel buildToolbar() {
|
||||
createButton.setToolTipText("<html>Create new privilege key<br>(Ctrl+N)");
|
||||
removeButton.setToolTipText("<html>Remove selected privilege keys<br>(Delete)");
|
||||
createButton.addActionListener(e -> createKey());
|
||||
removeButton.addActionListener(e -> removeKeys());
|
||||
|
||||
JPanel bar = new JPanel();
|
||||
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
|
||||
bar.setOpaque(false);
|
||||
bar.add(createButton);
|
||||
bar.add(Box.createHorizontalStrut(UIScale.scale(6)));
|
||||
bar.add(removeButton);
|
||||
bar.add(Box.createHorizontalGlue());
|
||||
return bar;
|
||||
}
|
||||
|
||||
private JScrollPane buildTable() {
|
||||
table.setFont(Theme.uiFont());
|
||||
table.setRowHeight(UIScale.scale(20));
|
||||
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
|
||||
table.setFillsViewportHeight(true);
|
||||
table.setRowSorter(new TableRowSorter<>(tableModel));
|
||||
table.getSelectionModel().addListSelectionListener(e -> updateButtons());
|
||||
table.setComponentPopupMenu(tablePopup());
|
||||
table.getTableHeader().setReorderingAllowed(true);
|
||||
KeyCellRenderer renderer = new KeyCellRenderer();
|
||||
for (Column c : Column.values()) {
|
||||
table.getColumnModel().getColumn(c.ordinal()).setPreferredWidth(UIScale.scale(c.width));
|
||||
table.getColumnModel().getColumn(c.ordinal()).setCellRenderer(renderer);
|
||||
}
|
||||
|
||||
JScrollPane scroll = new JScrollPane(table);
|
||||
scroll.getViewport().setBackground(Theme.treeBg());
|
||||
return scroll;
|
||||
}
|
||||
|
||||
private JPanel buildBottom() {
|
||||
JButton reload = new JButton("Reload", Icons.of("REFRESH"));
|
||||
reload.setToolTipText("<html>Reload privilege keys<br>(F5)");
|
||||
reload.addActionListener(e -> reload());
|
||||
JButton close = new JButton("Close");
|
||||
close.addActionListener(e -> dispose());
|
||||
|
||||
JPanel row = new JPanel(new BorderLayout());
|
||||
row.setOpaque(false);
|
||||
row.add(reload, BorderLayout.WEST);
|
||||
row.add(close, BorderLayout.EAST);
|
||||
|
||||
JPanel bottom = new JPanel(new BorderLayout(0, UIScale.scale(8)));
|
||||
bottom.setOpaque(false);
|
||||
bottom.add(status, BorderLayout.NORTH);
|
||||
bottom.add(row, BorderLayout.SOUTH);
|
||||
status.setForeground(Theme.chatEvent());
|
||||
return bottom;
|
||||
}
|
||||
|
||||
private JPopupMenu tablePopup() {
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
menu.add(item("Create", "ADD", this::createKey));
|
||||
menu.add(item("Remove", "DELETE", this::removeKeys));
|
||||
menu.add(item("Copy", "COPY", this::copyKeys));
|
||||
menu.addSeparator();
|
||||
menu.add(item("Reload", "REFRESH", this::reload));
|
||||
return menu;
|
||||
}
|
||||
|
||||
private static JMenuItem item(String text, String icon, Runnable action) {
|
||||
JMenuItem item = new JMenuItem(text, Icons.of(icon));
|
||||
item.addActionListener(e -> action.run());
|
||||
return item;
|
||||
}
|
||||
|
||||
private void bindKeys() {
|
||||
bind(getRootPane(), JComponent.WHEN_IN_FOCUSED_WINDOW, KeyStroke.getKeyStroke("control N"), this::createKey);
|
||||
bind(getRootPane(), JComponent.WHEN_IN_FOCUSED_WINDOW, KeyStroke.getKeyStroke("F5"), this::reload);
|
||||
bind(getRootPane(), JComponent.WHEN_IN_FOCUSED_WINDOW, KeyStroke.getKeyStroke("control W"), this::dispose);
|
||||
bind(table, JComponent.WHEN_FOCUSED, KeyStroke.getKeyStroke("control C"), this::copyKeys);
|
||||
bind(table, JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT, KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0),
|
||||
this::removeKeys);
|
||||
Dialogs.closeOnEscape(this);
|
||||
}
|
||||
|
||||
private static void bind(JComponent target, int condition, KeyStroke key, Runnable action) {
|
||||
String name = "ts3-privilegekeys-" + key;
|
||||
target.getInputMap(condition).put(key, name);
|
||||
target.getActionMap().put(name, new AbstractAction() {
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent e) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- data ----
|
||||
|
||||
/** Follows the server's name, as the ban list's title does. */
|
||||
void serverNameChanged() {
|
||||
String name = conn.getModel().getServerName();
|
||||
setTitle(name == null || name.isBlank() ? "Privilege Keys" : "Privilege Keys - " + name);
|
||||
}
|
||||
|
||||
void connectionLost() {
|
||||
dispose();
|
||||
}
|
||||
|
||||
private void reload() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
status.setText("Loading…");
|
||||
conn.requestPrivilegeKeys((keys, error) -> SwingUtilities.invokeLater(() -> {
|
||||
loading = false;
|
||||
if (!isDisplayable()) return;
|
||||
if (error != null) {
|
||||
tableModel.setKeys(List.of());
|
||||
status.setText("Failed to request Privilege Key list: " + error);
|
||||
} else {
|
||||
tableModel.setKeys(keys);
|
||||
status.setText(keys.size() == 1 ? "1 privilege key" : keys.size() + " privilege keys");
|
||||
}
|
||||
updateButtons();
|
||||
}));
|
||||
}
|
||||
|
||||
private List<PrivilegeKey> selectedKeys() {
|
||||
List<PrivilegeKey> selected = new ArrayList<>();
|
||||
for (int row : table.getSelectedRows()) selected.add(tableModel.get(table.convertRowIndexToModel(row)));
|
||||
return selected;
|
||||
}
|
||||
|
||||
private void updateButtons() {
|
||||
ServerModel model = conn.getModel();
|
||||
createButton.setEnabled(model.permits("b_virtualserver_token_add"));
|
||||
removeButton.setEnabled(!selectedKeys().isEmpty() && model.permits("b_virtualserver_token_delete"));
|
||||
}
|
||||
|
||||
// ---- actions ----
|
||||
|
||||
private void createKey() {
|
||||
if (!createButton.isEnabled()) return;
|
||||
new PrivilegeKeyCreateDialog(this, conn, groupIcons, this::reload).setVisible(true);
|
||||
}
|
||||
|
||||
private void removeKeys() {
|
||||
if (!removeButton.isEnabled()) return;
|
||||
int answer = JOptionPane.showConfirmDialog(this, "Do you really want to delete the selected privilege key(s)?",
|
||||
"Delete Privilege Keys", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
|
||||
if (answer != JOptionPane.YES_OPTION) return;
|
||||
List<String> tokens = selectedKeys().stream().map(PrivilegeKey::token).collect(Collectors.toList());
|
||||
conn.deletePrivilegeKeys(tokens, error -> SwingUtilities.invokeLater(() -> {
|
||||
if (!isDisplayable()) return;
|
||||
if (error != null) {
|
||||
JOptionPane.showMessageDialog(this, "Failed to delete Privilege Key: " + error, getTitle(),
|
||||
JOptionPane.ERROR_MESSAGE);
|
||||
}
|
||||
reload();
|
||||
}));
|
||||
}
|
||||
|
||||
private void copyKeys() {
|
||||
List<PrivilegeKey> keys = selectedKeys();
|
||||
if (keys.isEmpty()) return;
|
||||
String text = keys.stream().map(PrivilegeKey::token).collect(Collectors.joining("\n"));
|
||||
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(text), null);
|
||||
}
|
||||
|
||||
// ---- table ----
|
||||
|
||||
private Group groupOf(PrivilegeKey key) {
|
||||
ServerModel model = conn.getModel();
|
||||
return key.channelGroup() ? model.channelGroup(key.groupId()) : model.serverGroup(key.groupId());
|
||||
}
|
||||
|
||||
private final class KeyTableModel extends AbstractTableModel {
|
||||
|
||||
private List<PrivilegeKey> keys = List.of();
|
||||
|
||||
void setKeys(List<PrivilegeKey> keys) {
|
||||
this.keys = new ArrayList<>(keys);
|
||||
fireTableDataChanged();
|
||||
}
|
||||
|
||||
PrivilegeKey get(int row) {
|
||||
return keys.get(row);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRowCount() {
|
||||
return keys.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getColumnCount() {
|
||||
return Column.values().length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getColumnName(int column) {
|
||||
return Column.values()[column].title;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<?> getColumnClass(int column) {
|
||||
return Column.values()[column] == Column.CREATED ? Long.class : String.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getValueAt(int row, int column) {
|
||||
PrivilegeKey key = keys.get(row);
|
||||
if (Column.values()[column] == Column.CREATED) return key.createdEpochSeconds();
|
||||
return text(key, Column.values()[column]);
|
||||
}
|
||||
|
||||
String text(PrivilegeKey key, Column column) {
|
||||
switch (column) {
|
||||
case KEY:
|
||||
return key.token();
|
||||
case TYPE:
|
||||
return key.channelGroup() ? "Channel Group" : "Server Group";
|
||||
case GROUP:
|
||||
Group group = groupOf(key);
|
||||
return group != null ? group.name : Integer.toString(key.groupId());
|
||||
case CHANNEL:
|
||||
if (!key.channelGroup()) return "";
|
||||
String path = conn.getModel().channelPath(key.channelId());
|
||||
return path.isEmpty() ? Integer.toString(key.channelId()) : path;
|
||||
case CREATED:
|
||||
return key.createdEpochSeconds() <= 0 ? "" : DATE.format(
|
||||
Instant.ofEpochSecond(key.createdEpochSeconds()).atZone(ZoneId.systemDefault()));
|
||||
case DESCRIPTION:
|
||||
return key.description();
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows each column's text, with the group's icon beside its name. */
|
||||
private final class KeyCellRenderer extends DefaultTableCellRenderer {
|
||||
@Override
|
||||
public Component getTableCellRendererComponent(JTable t, Object value, boolean selected,
|
||||
boolean focused, int row, int column) {
|
||||
PrivilegeKey key = tableModel.get(t.convertRowIndexToModel(row));
|
||||
Column c = Column.values()[t.convertColumnIndexToModel(column)];
|
||||
super.getTableCellRendererComponent(t, tableModel.text(key, c), selected, focused, row, column);
|
||||
setIcon(c == Column.GROUP ? groupIcons.iconOf(groupOf(key)) : null);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import com.ts3client.sound.SoundNotifier;
|
||||
import com.ts3client.text.TsLink;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JSplitPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.Component;
|
||||
import java.awt.event.ComponentAdapter;
|
||||
import java.awt.event.ComponentEvent;
|
||||
@@ -52,6 +54,7 @@ final class ServerTab {
|
||||
|
||||
/** The one open ban list window for this server, if any. */
|
||||
private BanListDialog banList;
|
||||
private PrivilegeKeysDialog privilegeKeys;
|
||||
|
||||
ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
|
||||
SoundNotifier sounds, ContactStore contacts, PictureDownloads pictures) {
|
||||
@@ -187,11 +190,39 @@ final class ServerTab {
|
||||
banList.toFront();
|
||||
}
|
||||
|
||||
/** The server's name changed or the connection went; the ban list window follows. */
|
||||
void banListServerChanged(boolean connected) {
|
||||
if (banList == null) return;
|
||||
if (connected) banList.serverNameChanged();
|
||||
else banList.connectionLost();
|
||||
/** Asks for a privilege key and redeems it on this server. */
|
||||
void usePrivilegeKey() {
|
||||
if (!session.isConnected()) return;
|
||||
String key = (String) JOptionPane.showInputDialog(host, "Privilege Key:", "Use Privilege Key",
|
||||
JOptionPane.PLAIN_MESSAGE, Icons.of("TOKEN_USE"), null, null);
|
||||
if (key == null || key.isBlank()) return;
|
||||
conn.usePrivilegeKey(key.trim(), error -> {
|
||||
if (error == null) return;
|
||||
SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(host, error,
|
||||
"Error using Privilege Key", JOptionPane.ERROR_MESSAGE));
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens the server's privilege keys, or brings the already open window forward. */
|
||||
void showPrivilegeKeys() {
|
||||
if (!session.isConnected()) return;
|
||||
if (privilegeKeys == null || !privilegeKeys.isDisplayable()) {
|
||||
privilegeKeys = new PrivilegeKeysDialog(host, conn, groupIcons);
|
||||
}
|
||||
privilegeKeys.setVisible(true);
|
||||
privilegeKeys.toFront();
|
||||
}
|
||||
|
||||
/** The server's name changed or the connection went; the ban list and privilege keys windows follow. */
|
||||
void serverWindowsChanged(boolean connected) {
|
||||
if (banList != null) {
|
||||
if (connected) banList.serverNameChanged();
|
||||
else banList.connectionLost();
|
||||
}
|
||||
if (privilegeKeys != null) {
|
||||
if (connected) privilegeKeys.serverNameChanged();
|
||||
else privilegeKeys.connectionLost();
|
||||
}
|
||||
}
|
||||
|
||||
/** The contact list changed: names, colours and per-client playback follow. */
|
||||
|
||||
@@ -50,7 +50,7 @@ final class ServerTabConnectionEvents implements ServerSession.Listener {
|
||||
@Override
|
||||
public void onSessionChanged() {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
if (session.isConnected()) tab.banListServerChanged(true);
|
||||
if (session.isConnected()) tab.serverWindowsChanged(true);
|
||||
host.tabUpdated(tab);
|
||||
});
|
||||
}
|
||||
@@ -97,7 +97,7 @@ final class ServerTabConnectionEvents implements ServerSession.Listener {
|
||||
chatPanel.closeNoteTabs();
|
||||
chatPanel.setServerName(null);
|
||||
chatPanel.setChannelName(null);
|
||||
tab.banListServerChanged(false);
|
||||
tab.serverWindowsChanged(false);
|
||||
host.tabDisconnected(tab);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user