From 4911542d39a1f8180e7b00021ef83a2ec339a1c5 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 1 Oct 2026 23:00:27 +0000 Subject: [PATCH] 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 --- .../ts3client/net/ConnectionEventHandler.java | 2 + .../java/com/ts3client/net/PrivilegeKey.java | 36 ++ .../com/ts3client/net/PrivilegeKeyAdmin.java | 90 +++++ .../java/com/ts3client/net/ServerModel.java | 23 ++ .../ts3client/net/TeamspeakConnection.java | 40 +- .../com/ts3client/session/ServerSession.java | 5 + .../main/java/com/ts3client/ui/MainFrame.java | 15 + .../java/com/ts3client/ui/MainMenuBar.java | 36 +- .../ui/PrivilegeKeyCreateDialog.java | 168 +++++++++ .../com/ts3client/ui/PrivilegeKeysDialog.java | 355 ++++++++++++++++++ .../main/java/com/ts3client/ui/ServerTab.java | 41 +- .../ui/ServerTabConnectionEvents.java | 4 +- 12 files changed, 806 insertions(+), 9 deletions(-) create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/PrivilegeKey.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/PrivilegeKeyAdmin.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/PrivilegeKeyCreateDialog.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/PrivilegeKeysDialog.java diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java index 9f4c08e..9755087 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java @@ -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 ---- diff --git a/ts3-client/core/src/main/java/com/ts3client/net/PrivilegeKey.java b/ts3-client/core/src/main/java/com/ts3client/net/PrivilegeKey.java new file mode 100644 index 0000000..00d73f0 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/PrivilegeKey.java @@ -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 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; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/PrivilegeKeyAdmin.java b/ts3-client/core/src/main/java/com/ts3client/net/PrivilegeKeyAdmin.java new file mode 100644 index 0000000..ba52c20 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/PrivilegeKeyAdmin.java @@ -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. + * + *

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 listing; + /** The key the {@code tokenadd} in flight was answered with. */ + private volatile String created; + + PrivilegeKeyAdmin(TeamspeakConnection conn) { + this.conn = conn; + } + + List list() throws Exception { + List 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 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(); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index 4d286d7..2c882f6 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -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 privilegeKeyGroups(boolean channelGroups) { + List 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 channelsInTreeOrder() { + List out = new ArrayList<>(); + addInTreeOrder(0, out); + return out; + } + + private void addInTreeOrder(int parentId, List 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()) { diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index c65b813..e3717b0 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -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, 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 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 tokens, Consumer 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 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. */ diff --git a/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java index ad17fbd..7643a76 100644 --- a/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java +++ b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java @@ -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"); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 1b983cd..5d69a26 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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(); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java index 5467abc..a7975d5 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java @@ -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); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/PrivilegeKeyCreateDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/PrivilegeKeyCreateDialog.java new file mode 100644 index 0000000..1d9c00e --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/PrivilegeKeyCreateDialog.java @@ -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 groupBox = new JComboBox<>(); + private final JComboBox 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(); + })); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/PrivilegeKeysDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/PrivilegeKeysDialog.java new file mode 100644 index 0000000..a217cf6 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/PrivilegeKeysDialog.java @@ -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. + * + *

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("Create new privilege key
(Ctrl+N)"); + removeButton.setToolTipText("Remove selected privilege keys
(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("Reload privilege keys
(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 selectedKeys() { + List 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 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 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 keys = List.of(); + + void setKeys(List 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; + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java index a351080..f37ed55 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java @@ -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. */ diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java index 960d0dc..97dbe97 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java @@ -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); }); }